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 #[command(subcommand)]
439 Session(SessionCommand),
440 Setup {
445 #[arg(long)]
447 apply: bool,
448 },
449 Whois {
453 handle: Option<String>,
455 #[arg(long)]
456 json: bool,
457 #[arg(long)]
460 relay: Option<String>,
461 },
462 Add {
468 handle: String,
470 #[arg(long)]
472 relay: Option<String>,
473 #[arg(long)]
474 json: bool,
475 },
476 Up {
486 handle: String,
489 #[arg(long)]
491 name: Option<String>,
492 #[arg(long)]
493 json: bool,
494 },
495 Doctor {
502 #[arg(long)]
504 json: bool,
505 #[arg(long, default_value_t = 5)]
507 recent_rejections: usize,
508 },
509 Upgrade {
514 #[arg(long)]
517 check: bool,
518 #[arg(long)]
519 json: bool,
520 },
521 Service {
526 #[command(subcommand)]
527 action: ServiceAction,
528 },
529 Diag {
534 #[command(subcommand)]
535 action: DiagAction,
536 },
537 Claim {
541 nick: String,
542 #[arg(long)]
544 relay: Option<String>,
545 #[arg(long)]
547 public_url: Option<String>,
548 #[arg(long)]
549 json: bool,
550 },
551 Profile {
561 #[command(subcommand)]
562 action: ProfileAction,
563 },
564 Invite {
568 #[arg(long, default_value = "https://wireup.net")]
570 relay: String,
571 #[arg(long, default_value_t = 86_400)]
573 ttl: u64,
574 #[arg(long, default_value_t = 1)]
577 uses: u32,
578 #[arg(long)]
582 share: bool,
583 #[arg(long)]
585 json: bool,
586 },
587 Accept {
590 url: String,
592 #[arg(long)]
594 json: bool,
595 },
596 Reactor {
602 #[arg(long)]
604 on_event: String,
605 #[arg(long)]
607 peer: Option<String>,
608 #[arg(long)]
610 kind: Option<String>,
611 #[arg(long, default_value_t = true)]
613 verified_only: bool,
614 #[arg(long, default_value_t = 2)]
616 interval: u64,
617 #[arg(long)]
619 once: bool,
620 #[arg(long)]
622 dry_run: bool,
623 #[arg(long, default_value_t = 6)]
627 max_per_minute: u32,
628 #[arg(long, default_value_t = 1)]
632 max_chain_depth: u32,
633 },
634 Notify {
639 #[arg(long, default_value_t = 2)]
641 interval: u64,
642 #[arg(long)]
644 peer: Option<String>,
645 #[arg(long)]
647 once: bool,
648 #[arg(long)]
652 json: bool,
653 },
654}
655
656#[derive(Subcommand, Debug)]
657pub enum DiagAction {
658 Tail {
660 #[arg(long, default_value_t = 20)]
661 limit: usize,
662 #[arg(long)]
663 json: bool,
664 },
665 Enable,
668 Disable,
670 Status {
672 #[arg(long)]
673 json: bool,
674 },
675}
676
677#[derive(Subcommand, Debug)]
678pub enum SessionCommand {
679 New {
687 name: Option<String>,
689 #[arg(long, default_value = "https://wireup.net")]
691 relay: String,
692 #[arg(long)]
695 no_daemon: bool,
696 #[arg(long)]
698 json: bool,
699 },
700 List {
703 #[arg(long)]
704 json: bool,
705 },
706 Env {
710 name: Option<String>,
712 #[arg(long)]
713 json: bool,
714 },
715 Current {
719 #[arg(long)]
720 json: bool,
721 },
722 Destroy {
726 name: String,
727 #[arg(long)]
729 force: bool,
730 #[arg(long)]
731 json: bool,
732 },
733}
734
735#[derive(Subcommand, Debug)]
736pub enum ServiceAction {
737 Install {
740 #[arg(long)]
741 json: bool,
742 },
743 Uninstall {
747 #[arg(long)]
748 json: bool,
749 },
750 Status {
752 #[arg(long)]
753 json: bool,
754 },
755}
756
757#[derive(Subcommand, Debug)]
758pub enum ResponderCommand {
759 Set {
761 status: String,
763 #[arg(long)]
765 reason: Option<String>,
766 #[arg(long)]
768 json: bool,
769 },
770 Get {
772 peer: Option<String>,
774 #[arg(long)]
776 json: bool,
777 },
778}
779
780#[derive(Subcommand, Debug)]
781pub enum ProfileAction {
782 Set {
786 field: String,
787 value: String,
788 #[arg(long)]
789 json: bool,
790 },
791 Get {
793 #[arg(long)]
794 json: bool,
795 },
796 Clear {
798 field: String,
799 #[arg(long)]
800 json: bool,
801 },
802}
803
804pub fn run() -> Result<()> {
806 let cli = Cli::parse();
807 match cli.command {
808 Command::Init {
809 handle,
810 name,
811 relay,
812 json,
813 } => cmd_init(&handle, name.as_deref(), relay.as_deref(), json),
814 Command::Status { peer, json } => {
815 if let Some(peer) = peer {
816 cmd_status_peer(&peer, json)
817 } else {
818 cmd_status(json)
819 }
820 }
821 Command::Whoami { json } => cmd_whoami(json),
822 Command::Peers { json } => cmd_peers(json),
823 Command::Send {
824 peer,
825 kind_or_body,
826 body,
827 deadline,
828 json,
829 } => {
830 let (kind, body) = match body {
833 Some(real_body) => (kind_or_body, real_body),
834 None => ("claim".to_string(), kind_or_body),
835 };
836 cmd_send(&peer, &kind, &body, deadline.as_deref(), json)
837 }
838 Command::Tail { peer, json, limit } => cmd_tail(peer.as_deref(), json, limit),
839 Command::Monitor {
840 peer,
841 json,
842 include_handshake,
843 interval_ms,
844 replay,
845 } => cmd_monitor(peer.as_deref(), json, include_handshake, interval_ms, replay),
846 Command::Verify { path, json } => cmd_verify(&path, json),
847 Command::Responder { command } => match command {
848 ResponderCommand::Set {
849 status,
850 reason,
851 json,
852 } => cmd_responder_set(&status, reason.as_deref(), json),
853 ResponderCommand::Get { peer, json } => cmd_responder_get(peer.as_deref(), json),
854 },
855 Command::Mcp => cmd_mcp(),
856 Command::RelayServer { bind } => cmd_relay_server(&bind),
857 Command::BindRelay { url, json } => cmd_bind_relay(&url, json),
858 Command::AddPeerSlot {
859 handle,
860 url,
861 slot_id,
862 slot_token,
863 json,
864 } => cmd_add_peer_slot(&handle, &url, &slot_id, &slot_token, json),
865 Command::Push { peer, json } => cmd_push(peer.as_deref(), json),
866 Command::Pull { json } => cmd_pull(json),
867 Command::Pin { card_file, json } => cmd_pin(&card_file, json),
868 Command::RotateSlot { no_announce, json } => cmd_rotate_slot(no_announce, json),
869 Command::ForgetPeer {
870 handle,
871 purge,
872 json,
873 } => cmd_forget_peer(&handle, purge, json),
874 Command::Daemon {
875 interval,
876 once,
877 json,
878 } => cmd_daemon(interval, once, json),
879 Command::PairHost {
880 relay,
881 yes,
882 timeout,
883 detach,
884 json,
885 } => {
886 if detach {
887 cmd_pair_host_detach(&relay, json)
888 } else {
889 cmd_pair_host(&relay, yes, timeout)
890 }
891 }
892 Command::PairJoin {
893 code_phrase,
894 relay,
895 yes,
896 timeout,
897 detach,
898 json,
899 } => {
900 if detach {
901 cmd_pair_join_detach(&code_phrase, &relay, json)
902 } else {
903 cmd_pair_join(&code_phrase, &relay, yes, timeout)
904 }
905 }
906 Command::PairConfirm {
907 code_phrase,
908 digits,
909 json,
910 } => cmd_pair_confirm(&code_phrase, &digits, json),
911 Command::PairList {
912 json,
913 watch,
914 watch_interval,
915 } => cmd_pair_list(json, watch, watch_interval),
916 Command::PairCancel { code_phrase, json } => cmd_pair_cancel(&code_phrase, json),
917 Command::PairWatch {
918 code_phrase,
919 status,
920 timeout,
921 json,
922 } => cmd_pair_watch(&code_phrase, &status, timeout, json),
923 Command::Pair {
924 handle,
925 code,
926 relay,
927 yes,
928 timeout,
929 no_setup,
930 detach,
931 } => {
932 if handle.contains('@') && code.is_none() {
939 cmd_pair_megacommand(&handle, Some(&relay), timeout, false)
940 } else if detach {
941 cmd_pair_detach(&handle, code.as_deref(), &relay)
942 } else {
943 cmd_pair(&handle, code.as_deref(), &relay, yes, timeout, no_setup)
944 }
945 }
946 Command::PairAbandon { code_phrase, relay } => cmd_pair_abandon(&code_phrase, &relay),
947 Command::PairAccept { peer, json } => cmd_pair_accept(&peer, json),
948 Command::PairReject { peer, json } => cmd_pair_reject(&peer, json),
949 Command::PairListInbound { json } => cmd_pair_list_inbound(json),
950 Command::Session(cmd) => cmd_session(cmd),
951 Command::Invite {
952 relay,
953 ttl,
954 uses,
955 share,
956 json,
957 } => cmd_invite(&relay, ttl, uses, share, json),
958 Command::Accept { url, json } => cmd_accept(&url, json),
959 Command::Whois {
960 handle,
961 json,
962 relay,
963 } => cmd_whois(handle.as_deref(), json, relay.as_deref()),
964 Command::Add {
965 handle,
966 relay,
967 json,
968 } => cmd_add(&handle, relay.as_deref(), json),
969 Command::Up {
970 handle,
971 name,
972 json,
973 } => cmd_up(&handle, name.as_deref(), json),
974 Command::Doctor {
975 json,
976 recent_rejections,
977 } => cmd_doctor(json, recent_rejections),
978 Command::Upgrade { check, json } => cmd_upgrade(check, json),
979 Command::Service { action } => cmd_service(action),
980 Command::Diag { action } => cmd_diag(action),
981 Command::Claim {
982 nick,
983 relay,
984 public_url,
985 json,
986 } => cmd_claim(&nick, relay.as_deref(), public_url.as_deref(), json),
987 Command::Profile { action } => cmd_profile(action),
988 Command::Setup { apply } => cmd_setup(apply),
989 Command::Reactor {
990 on_event,
991 peer,
992 kind,
993 verified_only,
994 interval,
995 once,
996 dry_run,
997 max_per_minute,
998 max_chain_depth,
999 } => cmd_reactor(
1000 &on_event,
1001 peer.as_deref(),
1002 kind.as_deref(),
1003 verified_only,
1004 interval,
1005 once,
1006 dry_run,
1007 max_per_minute,
1008 max_chain_depth,
1009 ),
1010 Command::Notify {
1011 interval,
1012 peer,
1013 once,
1014 json,
1015 } => cmd_notify(interval, peer.as_deref(), once, json),
1016 }
1017}
1018
1019fn cmd_init(handle: &str, name: Option<&str>, relay: Option<&str>, as_json: bool) -> Result<()> {
1022 if !handle
1023 .chars()
1024 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
1025 {
1026 bail!("handle must be ASCII alphanumeric / '-' / '_' (got {handle:?})");
1027 }
1028 if config::is_initialized()? {
1029 bail!(
1030 "already initialized — config exists at {:?}. Delete it first if you want a fresh identity.",
1031 config::config_dir()?
1032 );
1033 }
1034
1035 config::ensure_dirs()?;
1036 let (sk_seed, pk_bytes) = generate_keypair();
1037 config::write_private_key(&sk_seed)?;
1038
1039 let card = build_agent_card(handle, &pk_bytes, name, None, None);
1040 let signed = sign_agent_card(&card, &sk_seed);
1041 config::write_agent_card(&signed)?;
1042
1043 let mut trust = empty_trust();
1044 add_self_to_trust(&mut trust, handle, &pk_bytes);
1045 config::write_trust(&trust)?;
1046
1047 let fp = fingerprint(&pk_bytes);
1048 let key_id = make_key_id(handle, &pk_bytes);
1049
1050 let mut relay_info: Option<(String, String)> = None;
1052 if let Some(url) = relay {
1053 let normalized = url.trim_end_matches('/');
1054 let client = crate::relay_client::RelayClient::new(normalized);
1055 client.check_healthz()?;
1056 let alloc = client.allocate_slot(Some(handle))?;
1057 let mut state = config::read_relay_state()?;
1058 state["self"] = json!({
1059 "relay_url": normalized,
1060 "slot_id": alloc.slot_id.clone(),
1061 "slot_token": alloc.slot_token,
1062 });
1063 config::write_relay_state(&state)?;
1064 relay_info = Some((normalized.to_string(), alloc.slot_id));
1065 }
1066
1067 let did_str = crate::agent_card::did_for_with_key(handle, &pk_bytes);
1068 if as_json {
1069 let mut out = json!({
1070 "did": did_str.clone(),
1071 "fingerprint": fp,
1072 "key_id": key_id,
1073 "config_dir": config::config_dir()?.to_string_lossy(),
1074 });
1075 if let Some((url, slot_id)) = &relay_info {
1076 out["relay_url"] = json!(url);
1077 out["slot_id"] = json!(slot_id);
1078 }
1079 println!("{}", serde_json::to_string(&out)?);
1080 } else {
1081 println!("generated {did_str} (ed25519:{key_id})");
1082 println!(
1083 "config written to {}",
1084 config::config_dir()?.to_string_lossy()
1085 );
1086 if let Some((url, slot_id)) = &relay_info {
1087 println!("bound to relay {url} (slot {slot_id})");
1088 println!();
1089 println!(
1090 "next step: `wire pair-host --relay {url}` to print a code phrase for a peer."
1091 );
1092 } else {
1093 println!();
1094 println!(
1095 "next step: `wire pair-host --relay <url>` to bind a relay + open a pair-slot."
1096 );
1097 }
1098 }
1099 Ok(())
1100}
1101
1102fn cmd_status(as_json: bool) -> Result<()> {
1105 let initialized = config::is_initialized()?;
1106
1107 let mut summary = json!({
1108 "initialized": initialized,
1109 });
1110
1111 if initialized {
1112 let card = config::read_agent_card()?;
1113 let did = card
1114 .get("did")
1115 .and_then(Value::as_str)
1116 .unwrap_or("")
1117 .to_string();
1118 let handle = card
1122 .get("handle")
1123 .and_then(Value::as_str)
1124 .map(str::to_string)
1125 .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
1126 let pk_b64 = card
1127 .get("verify_keys")
1128 .and_then(Value::as_object)
1129 .and_then(|m| m.values().next())
1130 .and_then(|v| v.get("key"))
1131 .and_then(Value::as_str)
1132 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
1133 let pk_bytes = crate::signing::b64decode(pk_b64)?;
1134 summary["did"] = json!(did);
1135 summary["handle"] = json!(handle);
1136 summary["fingerprint"] = json!(fingerprint(&pk_bytes));
1137 summary["capabilities"] = card
1138 .get("capabilities")
1139 .cloned()
1140 .unwrap_or_else(|| json!([]));
1141
1142 let trust = config::read_trust()?;
1143 let relay_state_for_tier = config::read_relay_state().unwrap_or_else(|_| json!({"peers": {}}));
1144 let mut peers = Vec::new();
1145 if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
1146 for (peer_handle, _agent) in agents {
1147 if peer_handle == &handle {
1148 continue; }
1150 peers.push(json!({
1155 "handle": peer_handle,
1156 "tier": effective_peer_tier(&trust, &relay_state_for_tier, peer_handle),
1157 }));
1158 }
1159 }
1160 summary["peers"] = json!(peers);
1161
1162 let relay_state = config::read_relay_state()?;
1163 summary["self_relay"] = relay_state.get("self").cloned().unwrap_or(Value::Null);
1164 if !summary["self_relay"].is_null() {
1165 if let Some(obj) = summary["self_relay"].as_object_mut() {
1167 obj.remove("slot_token");
1168 }
1169 }
1170 summary["peer_slots_count"] = json!(
1171 relay_state
1172 .get("peers")
1173 .and_then(Value::as_object)
1174 .map(|m| m.len())
1175 .unwrap_or(0)
1176 );
1177
1178 let outbox = config::outbox_dir()?;
1180 let inbox = config::inbox_dir()?;
1181 summary["outbox"] = json!(scan_jsonl_dir(&outbox)?);
1182 summary["inbox"] = json!(scan_jsonl_dir(&inbox)?);
1183
1184 let record = crate::ensure_up::read_pid_record("daemon");
1192 let pidfile_pid = record.pid();
1193 let pidfile_alive = pidfile_pid
1194 .map(|pid| {
1195 #[cfg(target_os = "linux")]
1196 {
1197 std::path::Path::new(&format!("/proc/{pid}")).exists()
1198 }
1199 #[cfg(not(target_os = "linux"))]
1200 {
1201 std::process::Command::new("kill")
1202 .args(["-0", &pid.to_string()])
1203 .output()
1204 .map(|o| o.status.success())
1205 .unwrap_or(false)
1206 }
1207 })
1208 .unwrap_or(false);
1209
1210 let pgrep_pids: Vec<u32> = std::process::Command::new("pgrep")
1212 .args(["-f", "wire daemon"])
1213 .output()
1214 .ok()
1215 .filter(|o| o.status.success())
1216 .map(|o| {
1217 String::from_utf8_lossy(&o.stdout)
1218 .split_whitespace()
1219 .filter_map(|s| s.parse::<u32>().ok())
1220 .collect()
1221 })
1222 .unwrap_or_default();
1223 let orphan_pids: Vec<u32> = pgrep_pids
1224 .iter()
1225 .filter(|p| Some(**p) != pidfile_pid)
1226 .copied()
1227 .collect();
1228
1229 let mut daemon = json!({
1230 "running": pidfile_alive,
1231 "pid": pidfile_pid,
1232 "all_running_pids": pgrep_pids,
1233 "orphans": orphan_pids,
1234 });
1235 if let crate::ensure_up::PidRecord::Json(d) = &record {
1236 daemon["version"] = json!(d.version);
1237 daemon["bin_path"] = json!(d.bin_path);
1238 daemon["did"] = json!(d.did);
1239 daemon["relay_url"] = json!(d.relay_url);
1240 daemon["started_at"] = json!(d.started_at);
1241 daemon["schema"] = json!(d.schema);
1242 if d.version != env!("CARGO_PKG_VERSION") {
1243 daemon["version_mismatch"] = json!({
1244 "daemon": d.version.clone(),
1245 "cli": env!("CARGO_PKG_VERSION"),
1246 });
1247 }
1248 } else if matches!(record, crate::ensure_up::PidRecord::LegacyInt(_)) {
1249 daemon["pidfile_form"] = json!("legacy-int");
1250 daemon["version_mismatch"] = json!({
1251 "daemon": "<pre-0.5.11>",
1252 "cli": env!("CARGO_PKG_VERSION"),
1253 });
1254 }
1255 summary["daemon"] = daemon;
1256
1257 let pending = crate::pending_pair::list_pending().unwrap_or_default();
1259 let mut counts: std::collections::BTreeMap<String, u32> = Default::default();
1260 for p in &pending {
1261 *counts.entry(p.status.clone()).or_default() += 1;
1262 }
1263 let pending_inbound =
1265 crate::pending_inbound_pair::list_pending_inbound().unwrap_or_default();
1266 let inbound_handles: Vec<&str> = pending_inbound
1267 .iter()
1268 .map(|p| p.peer_handle.as_str())
1269 .collect();
1270 summary["pending_pairs"] = json!({
1271 "total": pending.len(),
1272 "by_status": counts,
1273 "inbound_count": pending_inbound.len(),
1274 "inbound_handles": inbound_handles,
1275 });
1276 }
1277
1278 if as_json {
1279 println!("{}", serde_json::to_string(&summary)?);
1280 } else if !initialized {
1281 println!("not initialized — run `wire init <handle>` first");
1282 } else {
1283 println!("did: {}", summary["did"].as_str().unwrap_or("?"));
1284 println!(
1285 "fingerprint: {}",
1286 summary["fingerprint"].as_str().unwrap_or("?")
1287 );
1288 println!("capabilities: {}", summary["capabilities"]);
1289 if !summary["self_relay"].is_null() {
1290 println!(
1291 "self relay: {} (slot {})",
1292 summary["self_relay"]["relay_url"].as_str().unwrap_or("?"),
1293 summary["self_relay"]["slot_id"].as_str().unwrap_or("?")
1294 );
1295 } else {
1296 println!("self relay: (not bound — run `wire pair-host --relay <url>` to bind)");
1297 }
1298 println!(
1299 "peers: {}",
1300 summary["peers"].as_array().map(|a| a.len()).unwrap_or(0)
1301 );
1302 for p in summary["peers"].as_array().unwrap_or(&Vec::new()) {
1303 println!(
1304 " - {:<20} tier={}",
1305 p["handle"].as_str().unwrap_or(""),
1306 p["tier"].as_str().unwrap_or("?")
1307 );
1308 }
1309 println!(
1310 "outbox: {} file(s), {} event(s) queued",
1311 summary["outbox"]["files"].as_u64().unwrap_or(0),
1312 summary["outbox"]["events"].as_u64().unwrap_or(0)
1313 );
1314 println!(
1315 "inbox: {} file(s), {} event(s) received",
1316 summary["inbox"]["files"].as_u64().unwrap_or(0),
1317 summary["inbox"]["events"].as_u64().unwrap_or(0)
1318 );
1319 let daemon_running = summary["daemon"]["running"].as_bool().unwrap_or(false);
1320 let daemon_pid = summary["daemon"]["pid"]
1321 .as_u64()
1322 .map(|p| p.to_string())
1323 .unwrap_or_else(|| "—".to_string());
1324 let daemon_version = summary["daemon"]["version"].as_str().unwrap_or("");
1325 let version_suffix = if !daemon_version.is_empty() {
1326 format!(" v{daemon_version}")
1327 } else {
1328 String::new()
1329 };
1330 println!(
1331 "daemon: {} (pid {}{})",
1332 if daemon_running { "running" } else { "DOWN" },
1333 daemon_pid,
1334 version_suffix,
1335 );
1336 if let Some(mm) = summary["daemon"].get("version_mismatch") {
1338 println!(
1339 " !! version mismatch: daemon={} CLI={}. \
1340 run `wire upgrade` to swap atomically.",
1341 mm["daemon"].as_str().unwrap_or("?"),
1342 mm["cli"].as_str().unwrap_or("?"),
1343 );
1344 }
1345 if let Some(orphans) = summary["daemon"]["orphans"].as_array()
1346 && !orphans.is_empty()
1347 {
1348 let pids: Vec<String> = orphans
1349 .iter()
1350 .filter_map(|v| v.as_u64().map(|p| p.to_string()))
1351 .collect();
1352 println!(
1353 " !! orphan daemon process(es): pids {}. \
1354 pgrep saw them but pidfile didn't — likely stale process from \
1355 prior install. Multiple daemons race the relay cursor.",
1356 pids.join(", ")
1357 );
1358 }
1359 let pending_total = summary["pending_pairs"]["total"].as_u64().unwrap_or(0);
1360 let inbound_count = summary["pending_pairs"]["inbound_count"]
1361 .as_u64()
1362 .unwrap_or(0);
1363 if pending_total > 0 {
1364 print!("pending pairs: {pending_total}");
1365 if let Some(obj) = summary["pending_pairs"]["by_status"].as_object() {
1366 let parts: Vec<String> = obj
1367 .iter()
1368 .map(|(k, v)| format!("{}={}", k, v.as_u64().unwrap_or(0)))
1369 .collect();
1370 if !parts.is_empty() {
1371 print!(" ({})", parts.join(", "));
1372 }
1373 }
1374 println!();
1375 } else if inbound_count == 0 {
1376 println!("pending pairs: none");
1377 }
1378 if inbound_count > 0 {
1382 let handles: Vec<String> = summary["pending_pairs"]["inbound_handles"]
1383 .as_array()
1384 .map(|a| {
1385 a.iter()
1386 .filter_map(|v| v.as_str().map(str::to_string))
1387 .collect()
1388 })
1389 .unwrap_or_default();
1390 println!(
1391 "inbound pair requests ({inbound_count}): {} — `wire pair-list` to inspect, `wire pair-accept <peer>` to accept, `wire pair-reject <peer>` to refuse",
1392 handles.join(", "),
1393 );
1394 }
1395 }
1396 Ok(())
1397}
1398
1399fn scan_jsonl_dir(dir: &std::path::Path) -> Result<Value> {
1400 if !dir.exists() {
1401 return Ok(json!({"files": 0, "events": 0}));
1402 }
1403 let mut files = 0usize;
1404 let mut events = 0usize;
1405 for entry in std::fs::read_dir(dir)? {
1406 let path = entry?.path();
1407 if path.extension().map(|x| x == "jsonl").unwrap_or(false) {
1408 files += 1;
1409 if let Ok(body) = std::fs::read_to_string(&path) {
1410 events += body.lines().filter(|l| !l.trim().is_empty()).count();
1411 }
1412 }
1413 }
1414 Ok(json!({"files": files, "events": events}))
1415}
1416
1417fn responder_status_allowed(status: &str) -> bool {
1420 matches!(
1421 status,
1422 "online" | "offline" | "oauth_locked" | "rate_limited" | "degraded"
1423 )
1424}
1425
1426fn relay_slot_for(peer: Option<&str>) -> Result<(String, String, String, String)> {
1427 let state = config::read_relay_state()?;
1428 let (label, slot_info) = match peer {
1429 Some(peer) => (
1430 peer.to_string(),
1431 state
1432 .get("peers")
1433 .and_then(|p| p.get(peer))
1434 .ok_or_else(|| {
1435 anyhow!(
1436 "unknown peer {peer:?} in relay state — pair with them first:\n \
1437 wire add {peer}@wireup.net (or {peer}@<their-relay>)\n\
1438 (`wire peers` lists who you've already paired with.)"
1439 )
1440 })?,
1441 ),
1442 None => (
1443 "self".to_string(),
1444 state.get("self").filter(|v| !v.is_null()).ok_or_else(|| {
1445 anyhow!("self slot not bound — run `wire bind-relay <url>` first")
1446 })?,
1447 ),
1448 };
1449 let relay_url = slot_info["relay_url"]
1450 .as_str()
1451 .ok_or_else(|| anyhow!("{label} relay_url missing"))?
1452 .to_string();
1453 let slot_id = slot_info["slot_id"]
1454 .as_str()
1455 .ok_or_else(|| anyhow!("{label} slot_id missing"))?
1456 .to_string();
1457 let slot_token = slot_info["slot_token"]
1458 .as_str()
1459 .ok_or_else(|| anyhow!("{label} slot_token missing"))?
1460 .to_string();
1461 Ok((label, relay_url, slot_id, slot_token))
1462}
1463
1464fn cmd_responder_set(status: &str, reason: Option<&str>, as_json: bool) -> Result<()> {
1465 if !responder_status_allowed(status) {
1466 bail!("status must be one of: online, offline, oauth_locked, rate_limited, degraded");
1467 }
1468 let (_label, relay_url, slot_id, slot_token) = relay_slot_for(None)?;
1469 let now = time::OffsetDateTime::now_utc()
1470 .format(&time::format_description::well_known::Rfc3339)
1471 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
1472 let mut record = json!({
1473 "status": status,
1474 "set_at": now,
1475 });
1476 if let Some(reason) = reason {
1477 record["reason"] = json!(reason);
1478 }
1479 if status == "online" {
1480 record["last_success_at"] = json!(now);
1481 }
1482 let client = crate::relay_client::RelayClient::new(&relay_url);
1483 let saved = client.responder_health_set(&slot_id, &slot_token, &record)?;
1484 if as_json {
1485 println!("{}", serde_json::to_string(&saved)?);
1486 } else {
1487 let reason = saved
1488 .get("reason")
1489 .and_then(Value::as_str)
1490 .map(|r| format!(" — {r}"))
1491 .unwrap_or_default();
1492 println!(
1493 "responder {}{}",
1494 saved
1495 .get("status")
1496 .and_then(Value::as_str)
1497 .unwrap_or(status),
1498 reason
1499 );
1500 }
1501 Ok(())
1502}
1503
1504fn cmd_responder_get(peer: Option<&str>, as_json: bool) -> Result<()> {
1505 let (label, relay_url, slot_id, slot_token) = relay_slot_for(peer)?;
1506 let client = crate::relay_client::RelayClient::new(&relay_url);
1507 let health = client.responder_health_get(&slot_id, &slot_token)?;
1508 if as_json {
1509 println!(
1510 "{}",
1511 serde_json::to_string(&json!({
1512 "target": label,
1513 "responder_health": health,
1514 }))?
1515 );
1516 } else if health.is_null() {
1517 println!("{label}: responder health not reported");
1518 } else {
1519 let status = health
1520 .get("status")
1521 .and_then(Value::as_str)
1522 .unwrap_or("unknown");
1523 let reason = health
1524 .get("reason")
1525 .and_then(Value::as_str)
1526 .map(|r| format!(" — {r}"))
1527 .unwrap_or_default();
1528 let last_success = health
1529 .get("last_success_at")
1530 .and_then(Value::as_str)
1531 .map(|t| format!(" (last_success: {t})"))
1532 .unwrap_or_default();
1533 println!("{label}: {status}{reason}{last_success}");
1534 }
1535 Ok(())
1536}
1537
1538fn cmd_status_peer(peer: &str, as_json: bool) -> Result<()> {
1539 let (_label, relay_url, slot_id, slot_token) = relay_slot_for(Some(peer))?;
1540 let client = crate::relay_client::RelayClient::new(&relay_url);
1541
1542 let started = std::time::Instant::now();
1543 let transport_ok = client.healthz().unwrap_or(false);
1544 let latency_ms = started.elapsed().as_millis() as u64;
1545
1546 let (event_count, last_pull_at_unix) = client.slot_state(&slot_id, &slot_token)?;
1547 let now = std::time::SystemTime::now()
1548 .duration_since(std::time::UNIX_EPOCH)
1549 .map(|d| d.as_secs())
1550 .unwrap_or(0);
1551 let attention = match last_pull_at_unix {
1552 Some(last) if now.saturating_sub(last) <= 300 => json!({
1553 "status": "ok",
1554 "last_pull_at_unix": last,
1555 "age_seconds": now.saturating_sub(last),
1556 "event_count": event_count,
1557 }),
1558 Some(last) => json!({
1559 "status": "stale",
1560 "last_pull_at_unix": last,
1561 "age_seconds": now.saturating_sub(last),
1562 "event_count": event_count,
1563 }),
1564 None => json!({
1565 "status": "never_pulled",
1566 "last_pull_at_unix": Value::Null,
1567 "event_count": event_count,
1568 }),
1569 };
1570
1571 let responder_health = client.responder_health_get(&slot_id, &slot_token)?;
1572 let responder = if responder_health.is_null() {
1573 json!({"status": "not_reported", "record": Value::Null})
1574 } else {
1575 json!({
1576 "status": responder_health
1577 .get("status")
1578 .and_then(Value::as_str)
1579 .unwrap_or("unknown"),
1580 "record": responder_health,
1581 })
1582 };
1583
1584 let report = json!({
1585 "peer": peer,
1586 "transport": {
1587 "status": if transport_ok { "ok" } else { "error" },
1588 "relay_url": relay_url,
1589 "latency_ms": latency_ms,
1590 },
1591 "attention": attention,
1592 "responder": responder,
1593 });
1594
1595 if as_json {
1596 println!("{}", serde_json::to_string(&report)?);
1597 } else {
1598 let transport_line = if transport_ok {
1599 format!("ok relay reachable ({latency_ms}ms)")
1600 } else {
1601 "error relay unreachable".to_string()
1602 };
1603 println!("transport {transport_line}");
1604 match report["attention"]["status"].as_str().unwrap_or("unknown") {
1605 "ok" => println!(
1606 "attention ok last pull {}s ago",
1607 report["attention"]["age_seconds"].as_u64().unwrap_or(0)
1608 ),
1609 "stale" => println!(
1610 "attention stale last pull {}m ago",
1611 report["attention"]["age_seconds"].as_u64().unwrap_or(0) / 60
1612 ),
1613 "never_pulled" => println!("attention never pulled since relay reset"),
1614 other => println!("attention {other}"),
1615 }
1616 if report["responder"]["status"] == "not_reported" {
1617 println!("auto-responder not reported");
1618 } else {
1619 let record = &report["responder"]["record"];
1620 let status = record
1621 .get("status")
1622 .and_then(Value::as_str)
1623 .unwrap_or("unknown");
1624 let reason = record
1625 .get("reason")
1626 .and_then(Value::as_str)
1627 .map(|r| format!(" — {r}"))
1628 .unwrap_or_default();
1629 println!("auto-responder {status}{reason}");
1630 }
1631 }
1632 Ok(())
1633}
1634
1635fn cmd_whoami(as_json: bool) -> Result<()> {
1640 if !config::is_initialized()? {
1641 bail!("not initialized — run `wire init <handle>` first");
1642 }
1643 let card = config::read_agent_card()?;
1644 let did = card
1645 .get("did")
1646 .and_then(Value::as_str)
1647 .unwrap_or("")
1648 .to_string();
1649 let handle = card
1650 .get("handle")
1651 .and_then(Value::as_str)
1652 .map(str::to_string)
1653 .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
1654 let pk_b64 = card
1655 .get("verify_keys")
1656 .and_then(Value::as_object)
1657 .and_then(|m| m.values().next())
1658 .and_then(|v| v.get("key"))
1659 .and_then(Value::as_str)
1660 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
1661 let pk_bytes = crate::signing::b64decode(pk_b64)?;
1662 let fp = fingerprint(&pk_bytes);
1663 let key_id = make_key_id(&handle, &pk_bytes);
1664 let capabilities = card
1665 .get("capabilities")
1666 .cloned()
1667 .unwrap_or_else(|| json!(["wire/v3.1"]));
1668
1669 if as_json {
1670 println!(
1671 "{}",
1672 serde_json::to_string(&json!({
1673 "did": did,
1674 "handle": handle,
1675 "fingerprint": fp,
1676 "key_id": key_id,
1677 "public_key_b64": pk_b64,
1678 "capabilities": capabilities,
1679 "config_dir": config::config_dir()?.to_string_lossy(),
1680 }))?
1681 );
1682 } else {
1683 println!("{did} (ed25519:{key_id})");
1684 println!("fingerprint: {fp}");
1685 println!("capabilities: {capabilities}");
1686 }
1687 Ok(())
1688}
1689
1690fn effective_peer_tier(trust: &Value, relay_state: &Value, handle: &str) -> String {
1705 let raw = crate::trust::get_tier(trust, handle);
1706 if raw != "VERIFIED" {
1707 return raw.to_string();
1708 }
1709 let token = relay_state
1710 .get("peers")
1711 .and_then(|p| p.get(handle))
1712 .and_then(|p| p.get("slot_token"))
1713 .and_then(Value::as_str)
1714 .unwrap_or("");
1715 if token.is_empty() {
1716 "PENDING_ACK".to_string()
1717 } else {
1718 raw.to_string()
1719 }
1720}
1721
1722fn cmd_peers(as_json: bool) -> Result<()> {
1723 let trust = config::read_trust()?;
1724 let agents = trust
1725 .get("agents")
1726 .and_then(Value::as_object)
1727 .cloned()
1728 .unwrap_or_default();
1729 let relay_state = config::read_relay_state().unwrap_or_else(|_| json!({"peers": {}}));
1730
1731 let mut self_did: Option<String> = None;
1732 if let Ok(card) = config::read_agent_card() {
1733 self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
1734 }
1735
1736 let mut peers = Vec::new();
1737 for (handle, agent) in agents.iter() {
1738 let did = agent
1739 .get("did")
1740 .and_then(Value::as_str)
1741 .unwrap_or("")
1742 .to_string();
1743 if Some(did.as_str()) == self_did.as_deref() {
1744 continue; }
1746 let tier = effective_peer_tier(&trust, &relay_state, handle);
1747 let capabilities = agent
1748 .get("card")
1749 .and_then(|c| c.get("capabilities"))
1750 .cloned()
1751 .unwrap_or_else(|| json!([]));
1752 peers.push(json!({
1753 "handle": handle,
1754 "did": did,
1755 "tier": tier,
1756 "capabilities": capabilities,
1757 }));
1758 }
1759
1760 if as_json {
1761 println!("{}", serde_json::to_string(&peers)?);
1762 } else if peers.is_empty() {
1763 println!("no peers pinned (run `wire join <code>` to pair)");
1764 } else {
1765 for p in &peers {
1766 println!(
1767 "{:<20} {:<10} {}",
1768 p["handle"].as_str().unwrap_or(""),
1769 p["tier"].as_str().unwrap_or(""),
1770 p["did"].as_str().unwrap_or(""),
1771 );
1772 }
1773 }
1774 Ok(())
1775}
1776
1777fn maybe_warn_peer_attentiveness(peer: &str) {
1787 let state = match config::read_relay_state() {
1788 Ok(s) => s,
1789 Err(_) => return,
1790 };
1791 let p = state.get("peers").and_then(|p| p.get(peer));
1792 let slot_id = match p.and_then(|p| p.get("slot_id")).and_then(Value::as_str) {
1793 Some(s) if !s.is_empty() => s,
1794 _ => return,
1795 };
1796 let slot_token = match p.and_then(|p| p.get("slot_token")).and_then(Value::as_str) {
1797 Some(s) if !s.is_empty() => s,
1798 _ => return,
1799 };
1800 let relay_url = match p.and_then(|p| p.get("relay_url")).and_then(Value::as_str) {
1801 Some(s) if !s.is_empty() => s.to_string(),
1802 _ => match state
1803 .get("self")
1804 .and_then(|s| s.get("relay_url"))
1805 .and_then(Value::as_str)
1806 {
1807 Some(s) if !s.is_empty() => s.to_string(),
1808 _ => return,
1809 },
1810 };
1811 let client = crate::relay_client::RelayClient::new(&relay_url);
1812 let (_count, last_pull) = match client.slot_state(slot_id, slot_token) {
1813 Ok(t) => t,
1814 Err(_) => return,
1815 };
1816 let now = std::time::SystemTime::now()
1817 .duration_since(std::time::UNIX_EPOCH)
1818 .map(|d| d.as_secs())
1819 .unwrap_or(0);
1820 match last_pull {
1821 None => {
1822 eprintln!(
1823 "phyllis: {peer}'s line is silent — relay sees no pulls yet. message will queue, but they may not be listening."
1824 );
1825 }
1826 Some(t) if now.saturating_sub(t) > 300 => {
1827 let mins = now.saturating_sub(t) / 60;
1828 eprintln!(
1829 "phyllis: {peer} hasn't picked up in {mins}m — message will queue, but they may be away."
1830 );
1831 }
1832 _ => {}
1833 }
1834}
1835
1836pub(crate) fn parse_deadline_until(input: &str) -> Result<String> {
1837 let trimmed = input.trim();
1838 if time::OffsetDateTime::parse(trimmed, &time::format_description::well_known::Rfc3339).is_ok()
1839 {
1840 return Ok(trimmed.to_string());
1841 }
1842 let (amount, unit) = trimmed.split_at(trimmed.len().saturating_sub(1));
1843 let n: i64 = amount
1844 .parse()
1845 .with_context(|| format!("deadline must be `30m`, `2h`, `1d`, or RFC3339: {input:?}"))?;
1846 if n <= 0 {
1847 bail!("deadline duration must be positive: {input:?}");
1848 }
1849 let duration = match unit {
1850 "m" => time::Duration::minutes(n),
1851 "h" => time::Duration::hours(n),
1852 "d" => time::Duration::days(n),
1853 _ => bail!("deadline must end in m, h, d, or be RFC3339: {input:?}"),
1854 };
1855 Ok((time::OffsetDateTime::now_utc() + duration)
1856 .format(&time::format_description::well_known::Rfc3339)
1857 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()))
1858}
1859
1860fn cmd_send(
1861 peer: &str,
1862 kind: &str,
1863 body_arg: &str,
1864 deadline: Option<&str>,
1865 as_json: bool,
1866) -> Result<()> {
1867 if !config::is_initialized()? {
1868 bail!("not initialized — run `wire init <handle>` first");
1869 }
1870 let peer = crate::agent_card::bare_handle(peer);
1871 let sk_seed = config::read_private_key()?;
1872 let card = config::read_agent_card()?;
1873 let did = card.get("did").and_then(Value::as_str).unwrap_or("");
1874 let handle = crate::agent_card::display_handle_from_did(did).to_string();
1875 let pk_b64 = card
1876 .get("verify_keys")
1877 .and_then(Value::as_object)
1878 .and_then(|m| m.values().next())
1879 .and_then(|v| v.get("key"))
1880 .and_then(Value::as_str)
1881 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
1882 let pk_bytes = crate::signing::b64decode(pk_b64)?;
1883
1884 let body_value: Value = if body_arg == "-" {
1889 use std::io::Read;
1890 let mut raw = String::new();
1891 std::io::stdin()
1892 .read_to_string(&mut raw)
1893 .with_context(|| "reading body from stdin")?;
1894 serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
1897 } else if let Some(path) = body_arg.strip_prefix('@') {
1898 let raw =
1899 std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
1900 serde_json::from_str(&raw).unwrap_or(Value::String(raw))
1901 } else {
1902 Value::String(body_arg.to_string())
1903 };
1904
1905 let kind_id = parse_kind(kind)?;
1906
1907 let now = time::OffsetDateTime::now_utc()
1908 .format(&time::format_description::well_known::Rfc3339)
1909 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
1910
1911 let mut event = json!({
1912 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
1913 "timestamp": now,
1914 "from": did,
1915 "to": format!("did:wire:{peer}"),
1916 "type": kind,
1917 "kind": kind_id,
1918 "body": body_value,
1919 });
1920 if let Some(deadline) = deadline {
1921 event["time_sensitive_until"] = json!(parse_deadline_until(deadline)?);
1922 }
1923 let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)?;
1924 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
1925
1926 maybe_warn_peer_attentiveness(peer);
1931
1932 let line = serde_json::to_vec(&signed)?;
1937 let outbox = config::append_outbox_record(peer, &line)?;
1938
1939 if as_json {
1940 println!(
1941 "{}",
1942 serde_json::to_string(&json!({
1943 "event_id": event_id,
1944 "status": "queued",
1945 "peer": peer,
1946 "outbox": outbox.to_string_lossy(),
1947 }))?
1948 );
1949 } else {
1950 println!(
1951 "queued event {event_id} → {peer} (outbox: {})",
1952 outbox.display()
1953 );
1954 }
1955 Ok(())
1956}
1957
1958fn parse_kind(s: &str) -> Result<u32> {
1959 if let Ok(n) = s.parse::<u32>() {
1960 return Ok(n);
1961 }
1962 for (id, name) in crate::signing::kinds() {
1963 if *name == s {
1964 return Ok(*id);
1965 }
1966 }
1967 Ok(1)
1969}
1970
1971fn cmd_tail(peer: Option<&str>, as_json: bool, limit: usize) -> Result<()> {
1974 let inbox = config::inbox_dir()?;
1975 if !inbox.exists() {
1976 if !as_json {
1977 eprintln!("no inbox yet — daemon hasn't run, or no events received");
1978 }
1979 return Ok(());
1980 }
1981 let trust = config::read_trust()?;
1982 let mut count = 0usize;
1983
1984 let entries: Vec<_> = std::fs::read_dir(&inbox)?
1985 .filter_map(|e| e.ok())
1986 .map(|e| e.path())
1987 .filter(|p| {
1988 p.extension().map(|x| x == "jsonl").unwrap_or(false)
1989 && match peer {
1990 Some(want) => p.file_stem().and_then(|s| s.to_str()) == Some(want),
1991 None => true,
1992 }
1993 })
1994 .collect();
1995
1996 for path in entries {
1997 let body = std::fs::read_to_string(&path)?;
1998 for line in body.lines() {
1999 let event: Value = match serde_json::from_str(line) {
2000 Ok(v) => v,
2001 Err(_) => continue,
2002 };
2003 let verified = verify_message_v31(&event, &trust).is_ok();
2004 if as_json {
2005 let mut event_with_meta = event.clone();
2006 if let Some(obj) = event_with_meta.as_object_mut() {
2007 obj.insert("verified".into(), json!(verified));
2008 }
2009 println!("{}", serde_json::to_string(&event_with_meta)?);
2010 } else {
2011 let ts = event
2012 .get("timestamp")
2013 .and_then(Value::as_str)
2014 .unwrap_or("?");
2015 let from = event.get("from").and_then(Value::as_str).unwrap_or("?");
2016 let kind = event.get("kind").and_then(Value::as_u64).unwrap_or(0);
2017 let kind_name = event.get("type").and_then(Value::as_str).unwrap_or("?");
2018 let summary = event
2019 .get("body")
2020 .map(|b| match b {
2021 Value::String(s) => s.clone(),
2022 _ => b.to_string(),
2023 })
2024 .unwrap_or_default();
2025 let mark = if verified { "✓" } else { "✗" };
2026 let deadline = event
2027 .get("time_sensitive_until")
2028 .and_then(Value::as_str)
2029 .map(|d| format!(" deadline: {d}"))
2030 .unwrap_or_default();
2031 println!("[{ts} {from} kind={kind} {kind_name}{deadline}] {summary} | sig {mark}");
2032 }
2033 count += 1;
2034 if limit > 0 && count >= limit {
2035 return Ok(());
2036 }
2037 }
2038 }
2039 Ok(())
2040}
2041
2042fn monitor_is_noise_kind(kind: &str) -> bool {
2048 matches!(kind, "pair_drop" | "pair_drop_ack" | "heartbeat")
2049}
2050
2051fn monitor_render(e: &crate::inbox_watch::InboxEvent, as_json: bool) -> Result<String> {
2055 if as_json {
2056 Ok(serde_json::to_string(e)?)
2057 } else {
2058 let eid_short: String = e.event_id.chars().take(12).collect();
2059 let body = e.body_preview.replace('\n', " ");
2060 let ts: String = e.timestamp.chars().take(19).collect();
2061 Ok(format!("[{ts}] {}/{} ({eid_short}) {body}", e.peer, e.kind))
2062 }
2063}
2064
2065fn cmd_monitor(
2081 peer_filter: Option<&str>,
2082 as_json: bool,
2083 include_handshake: bool,
2084 interval_ms: u64,
2085 replay: usize,
2086) -> Result<()> {
2087 let inbox_dir = config::inbox_dir()?;
2088 if !inbox_dir.exists() {
2089 if !as_json {
2090 eprintln!(
2091 "wire monitor: inbox dir {inbox_dir:?} missing — has the daemon ever run?"
2092 );
2093 }
2094 }
2096
2097 if replay > 0 && inbox_dir.exists() {
2101 let mut all: Vec<crate::inbox_watch::InboxEvent> = Vec::new();
2102 for entry in std::fs::read_dir(&inbox_dir)?.flatten() {
2103 let path = entry.path();
2104 if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
2105 continue;
2106 }
2107 let peer = match path.file_stem().and_then(|s| s.to_str()) {
2108 Some(s) => s.to_string(),
2109 None => continue,
2110 };
2111 if let Some(filter) = peer_filter {
2112 if peer != filter {
2113 continue;
2114 }
2115 }
2116 let body = std::fs::read_to_string(&path).unwrap_or_default();
2117 for line in body.lines() {
2118 let line = line.trim();
2119 if line.is_empty() {
2120 continue;
2121 }
2122 let signed: Value = match serde_json::from_str(line) {
2123 Ok(v) => v,
2124 Err(_) => continue,
2125 };
2126 let ev = crate::inbox_watch::InboxEvent::from_signed(
2127 &peer,
2128 signed,
2129 true,
2130 );
2131 if !include_handshake && monitor_is_noise_kind(&ev.kind) {
2132 continue;
2133 }
2134 all.push(ev);
2135 }
2136 }
2137 all.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
2140 let start = all.len().saturating_sub(replay);
2141 for ev in &all[start..] {
2142 println!("{}", monitor_render(ev, as_json)?);
2143 }
2144 use std::io::Write;
2145 std::io::stdout().flush().ok();
2146 }
2147
2148 let mut w = crate::inbox_watch::InboxWatcher::from_head()?;
2151 let sleep_dur = std::time::Duration::from_millis(interval_ms.max(50));
2152
2153 loop {
2154 let events = w.poll()?;
2155 let mut wrote = false;
2156 for ev in events {
2157 if let Some(filter) = peer_filter {
2158 if ev.peer != filter {
2159 continue;
2160 }
2161 }
2162 if !include_handshake && monitor_is_noise_kind(&ev.kind) {
2163 continue;
2164 }
2165 println!("{}", monitor_render(&ev, as_json)?);
2166 wrote = true;
2167 }
2168 if wrote {
2169 use std::io::Write;
2170 std::io::stdout().flush().ok();
2171 }
2172 std::thread::sleep(sleep_dur);
2173 }
2174}
2175
2176#[cfg(test)]
2177mod tier_tests {
2178 use super::*;
2179 use serde_json::json;
2180
2181 fn trust_with(handle: &str, tier: &str) -> Value {
2182 json!({
2183 "version": 1,
2184 "agents": {
2185 handle: {
2186 "tier": tier,
2187 "did": format!("did:wire:{handle}"),
2188 "card": {"capabilities": ["wire/v3.1"]}
2189 }
2190 }
2191 })
2192 }
2193
2194 #[test]
2195 fn pending_ack_when_verified_but_no_slot_token() {
2196 let trust = trust_with("willard", "VERIFIED");
2200 let relay_state = json!({
2201 "peers": {
2202 "willard": {
2203 "relay_url": "https://relay",
2204 "slot_id": "abc",
2205 "slot_token": "",
2206 }
2207 }
2208 });
2209 assert_eq!(
2210 effective_peer_tier(&trust, &relay_state, "willard"),
2211 "PENDING_ACK"
2212 );
2213 }
2214
2215 #[test]
2216 fn verified_when_slot_token_present() {
2217 let trust = trust_with("willard", "VERIFIED");
2218 let relay_state = json!({
2219 "peers": {
2220 "willard": {
2221 "relay_url": "https://relay",
2222 "slot_id": "abc",
2223 "slot_token": "tok123",
2224 }
2225 }
2226 });
2227 assert_eq!(
2228 effective_peer_tier(&trust, &relay_state, "willard"),
2229 "VERIFIED"
2230 );
2231 }
2232
2233 #[test]
2234 fn raw_tier_passes_through_for_non_verified() {
2235 let trust = trust_with("willard", "UNTRUSTED");
2238 let relay_state = json!({
2239 "peers": {"willard": {"slot_token": ""}}
2240 });
2241 assert_eq!(
2242 effective_peer_tier(&trust, &relay_state, "willard"),
2243 "UNTRUSTED"
2244 );
2245 }
2246
2247 #[test]
2248 fn pending_ack_when_relay_state_missing_peer() {
2249 let trust = trust_with("willard", "VERIFIED");
2253 let relay_state = json!({"peers": {}});
2254 assert_eq!(
2255 effective_peer_tier(&trust, &relay_state, "willard"),
2256 "PENDING_ACK"
2257 );
2258 }
2259}
2260
2261#[cfg(test)]
2262mod monitor_tests {
2263 use super::*;
2264 use crate::inbox_watch::InboxEvent;
2265 use serde_json::Value;
2266
2267 fn ev(peer: &str, kind: &str, body: &str) -> InboxEvent {
2268 InboxEvent {
2269 peer: peer.to_string(),
2270 event_id: "abcd1234567890ef".to_string(),
2271 kind: kind.to_string(),
2272 body_preview: body.to_string(),
2273 verified: true,
2274 timestamp: "2026-05-15T23:14:07.123456Z".to_string(),
2275 raw: Value::Null,
2276 }
2277 }
2278
2279 #[test]
2280 fn monitor_filter_drops_handshake_kinds_by_default() {
2281 assert!(monitor_is_noise_kind("pair_drop"));
2286 assert!(monitor_is_noise_kind("pair_drop_ack"));
2287 assert!(monitor_is_noise_kind("heartbeat"));
2288
2289 assert!(!monitor_is_noise_kind("claim"));
2291 assert!(!monitor_is_noise_kind("decision"));
2292 assert!(!monitor_is_noise_kind("ack"));
2293 assert!(!monitor_is_noise_kind("request"));
2294 assert!(!monitor_is_noise_kind("note"));
2295 assert!(!monitor_is_noise_kind("future_kind_we_dont_know"));
2299 }
2300
2301 #[test]
2302 fn monitor_render_plain_is_one_short_line() {
2303 let e = ev("willard", "claim", "real v8 train shipped 1350 steps");
2304 let line = monitor_render(&e, false).unwrap();
2305 assert!(!line.contains('\n'), "render must be one line: {line}");
2307 assert!(line.contains("willard"));
2309 assert!(line.contains("claim"));
2310 assert!(line.contains("real v8 train"));
2311 assert!(line.contains("abcd12345678"));
2313 assert!(!line.contains("abcd1234567890ef"), "should truncate full id");
2314 assert!(line.contains("2026-05-15T23:14:07"));
2316 }
2317
2318 #[test]
2319 fn monitor_render_strips_newlines_from_body() {
2320 let e = ev("spark", "claim", "line one\nline two\nline three");
2325 let line = monitor_render(&e, false).unwrap();
2326 assert!(!line.contains('\n'), "newlines must be stripped: {line}");
2327 assert!(line.contains("line one line two line three"));
2328 }
2329
2330 #[test]
2331 fn monitor_render_json_is_valid_jsonl() {
2332 let e = ev("spark", "claim", "hi");
2333 let line = monitor_render(&e, true).unwrap();
2334 assert!(!line.contains('\n'));
2335 let parsed: Value = serde_json::from_str(&line).expect("valid JSONL");
2336 assert_eq!(parsed["peer"], "spark");
2337 assert_eq!(parsed["kind"], "claim");
2338 assert_eq!(parsed["body_preview"], "hi");
2339 }
2340
2341 #[test]
2342 fn monitor_does_not_drop_on_verified_null() {
2343 let mut e = ev("spark", "claim", "from disk with verified=null");
2354 e.verified = false; let line = monitor_render(&e, false).unwrap();
2356 assert!(line.contains("from disk with verified=null"));
2357 assert!(!monitor_is_noise_kind("claim"));
2359 }
2360}
2361
2362fn cmd_verify(path: &str, as_json: bool) -> Result<()> {
2365 let body = if path == "-" {
2366 let mut buf = String::new();
2367 use std::io::Read;
2368 std::io::stdin().read_to_string(&mut buf)?;
2369 buf
2370 } else {
2371 std::fs::read_to_string(path).with_context(|| format!("reading {path}"))?
2372 };
2373 let event: Value = serde_json::from_str(&body)?;
2374 let trust = config::read_trust()?;
2375 match verify_message_v31(&event, &trust) {
2376 Ok(()) => {
2377 if as_json {
2378 println!("{}", serde_json::to_string(&json!({"verified": true}))?);
2379 } else {
2380 println!("verified ✓");
2381 }
2382 Ok(())
2383 }
2384 Err(e) => {
2385 let reason = e.to_string();
2386 if as_json {
2387 println!(
2388 "{}",
2389 serde_json::to_string(&json!({"verified": false, "reason": reason}))?
2390 );
2391 } else {
2392 eprintln!("FAILED: {reason}");
2393 }
2394 std::process::exit(1);
2395 }
2396 }
2397}
2398
2399fn cmd_mcp() -> Result<()> {
2402 crate::mcp::run()
2403}
2404
2405fn cmd_relay_server(bind: &str) -> Result<()> {
2406 let state_dir = if let Ok(home) = std::env::var("WIRE_HOME") {
2410 std::path::PathBuf::from(home)
2411 .join("state")
2412 .join("wire-relay")
2413 } else {
2414 dirs::state_dir()
2415 .or_else(dirs::data_local_dir)
2416 .ok_or_else(|| anyhow::anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?
2417 .join("wire-relay")
2418 };
2419 let runtime = tokio::runtime::Builder::new_multi_thread()
2420 .enable_all()
2421 .build()?;
2422 runtime.block_on(crate::relay_server::serve(bind, state_dir))
2423}
2424
2425fn cmd_bind_relay(url: &str, as_json: bool) -> Result<()> {
2428 if !config::is_initialized()? {
2429 bail!("not initialized — run `wire init <handle>` first");
2430 }
2431 let card = config::read_agent_card()?;
2432 let did = card.get("did").and_then(Value::as_str).unwrap_or("");
2433 let handle = crate::agent_card::display_handle_from_did(did).to_string();
2434
2435 let normalized = url.trim_end_matches('/');
2436 let client = crate::relay_client::RelayClient::new(normalized);
2437 client.check_healthz()?;
2438 let alloc = client.allocate_slot(Some(&handle))?;
2439 let mut state = config::read_relay_state()?;
2440 state["self"] = json!({
2441 "relay_url": url,
2442 "slot_id": alloc.slot_id,
2443 "slot_token": alloc.slot_token,
2444 });
2445 config::write_relay_state(&state)?;
2446
2447 if as_json {
2448 println!(
2449 "{}",
2450 serde_json::to_string(&json!({
2451 "relay_url": url,
2452 "slot_id": alloc.slot_id,
2453 "slot_token_present": true,
2454 }))?
2455 );
2456 } else {
2457 println!("bound to relay {url}");
2458 println!("slot_id: {}", alloc.slot_id);
2459 println!(
2460 "(slot_token written to {} mode 0600)",
2461 config::relay_state_path()?.display()
2462 );
2463 }
2464 Ok(())
2465}
2466
2467fn cmd_add_peer_slot(
2470 handle: &str,
2471 url: &str,
2472 slot_id: &str,
2473 slot_token: &str,
2474 as_json: bool,
2475) -> Result<()> {
2476 let mut state = config::read_relay_state()?;
2477 let peers = state["peers"]
2478 .as_object_mut()
2479 .ok_or_else(|| anyhow!("relay state missing 'peers' object"))?;
2480 peers.insert(
2481 handle.to_string(),
2482 json!({
2483 "relay_url": url,
2484 "slot_id": slot_id,
2485 "slot_token": slot_token,
2486 }),
2487 );
2488 config::write_relay_state(&state)?;
2489 if as_json {
2490 println!(
2491 "{}",
2492 serde_json::to_string(&json!({
2493 "handle": handle,
2494 "relay_url": url,
2495 "slot_id": slot_id,
2496 "added": true,
2497 }))?
2498 );
2499 } else {
2500 println!("pinned peer slot for {handle} at {url} ({slot_id})");
2501 }
2502 Ok(())
2503}
2504
2505fn cmd_push(peer_filter: Option<&str>, as_json: bool) -> Result<()> {
2508 let state = config::read_relay_state()?;
2509 let peers = state["peers"].as_object().cloned().unwrap_or_default();
2510 if peers.is_empty() {
2511 bail!(
2512 "no peer slots pinned — run `wire add-peer-slot <handle> <url> <slot_id> <token>` first"
2513 );
2514 }
2515 let outbox_dir = config::outbox_dir()?;
2516 if outbox_dir.exists() {
2521 let pinned: std::collections::HashSet<String> = peers.keys().cloned().collect();
2522 for entry in std::fs::read_dir(&outbox_dir)?.flatten() {
2523 let path = entry.path();
2524 if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
2525 continue;
2526 }
2527 let stem = match path.file_stem().and_then(|s| s.to_str()) {
2528 Some(s) => s.to_string(),
2529 None => continue,
2530 };
2531 if pinned.contains(&stem) {
2532 continue;
2533 }
2534 let bare = crate::agent_card::bare_handle(&stem);
2537 if pinned.contains(bare) {
2538 eprintln!(
2539 "wire push: WARN stale outbox file `{}.jsonl` not enumerated (pinned peer is `{bare}`). \
2540 Merge with: `cat {} >> {}` then delete the FQDN file.",
2541 stem,
2542 path.display(),
2543 outbox_dir.join(format!("{bare}.jsonl")).display(),
2544 );
2545 }
2546 }
2547 }
2548 if !outbox_dir.exists() {
2549 if as_json {
2550 println!(
2551 "{}",
2552 serde_json::to_string(&json!({"pushed": [], "skipped": []}))?
2553 );
2554 } else {
2555 println!("phyllis: nothing to dial out — write a message first with `wire send`");
2556 }
2557 return Ok(());
2558 }
2559
2560 let mut pushed = Vec::new();
2561 let mut skipped = Vec::new();
2562
2563 for (peer_handle, slot_info) in peers.iter() {
2564 if let Some(want) = peer_filter
2565 && peer_handle != want
2566 {
2567 continue;
2568 }
2569 let outbox = outbox_dir.join(format!("{peer_handle}.jsonl"));
2570 if !outbox.exists() {
2571 continue;
2572 }
2573 let url = slot_info["relay_url"]
2574 .as_str()
2575 .ok_or_else(|| anyhow!("peer {peer_handle} missing relay_url"))?;
2576 let slot_id = slot_info["slot_id"]
2577 .as_str()
2578 .ok_or_else(|| anyhow!("peer {peer_handle} missing slot_id"))?;
2579 let slot_token = slot_info["slot_token"]
2580 .as_str()
2581 .ok_or_else(|| anyhow!("peer {peer_handle} missing slot_token"))?;
2582 let client = crate::relay_client::RelayClient::new(url);
2583 let body = std::fs::read_to_string(&outbox)?;
2584 for line in body.lines() {
2585 let event: Value = match serde_json::from_str(line) {
2586 Ok(v) => v,
2587 Err(_) => continue,
2588 };
2589 let event_id = event
2590 .get("event_id")
2591 .and_then(Value::as_str)
2592 .unwrap_or("")
2593 .to_string();
2594 match client.post_event(slot_id, slot_token, &event) {
2595 Ok(resp) => {
2596 if resp.status == "duplicate" {
2597 skipped.push(json!({"peer": peer_handle, "event_id": event_id, "reason": "duplicate"}));
2598 } else {
2599 pushed.push(json!({"peer": peer_handle, "event_id": event_id}));
2600 }
2601 }
2602 Err(e) => {
2603 let reason = crate::relay_client::format_transport_error(&e);
2607 skipped.push(
2608 json!({"peer": peer_handle, "event_id": event_id, "reason": reason}),
2609 );
2610 }
2611 }
2612 }
2613 }
2614
2615 if as_json {
2616 println!(
2617 "{}",
2618 serde_json::to_string(&json!({"pushed": pushed, "skipped": skipped}))?
2619 );
2620 } else {
2621 println!(
2622 "pushed {} event(s); skipped {} ({})",
2623 pushed.len(),
2624 skipped.len(),
2625 if skipped.is_empty() {
2626 "none"
2627 } else {
2628 "see --json for detail"
2629 }
2630 );
2631 }
2632 Ok(())
2633}
2634
2635fn cmd_pull(as_json: bool) -> Result<()> {
2638 let state = config::read_relay_state()?;
2639 let self_state = state.get("self").cloned().unwrap_or(Value::Null);
2640 if self_state.is_null() {
2641 bail!("self slot not bound — run `wire bind-relay <url>` first");
2642 }
2643 let url = self_state["relay_url"]
2644 .as_str()
2645 .ok_or_else(|| anyhow!("self.relay_url missing"))?;
2646 let slot_id = self_state["slot_id"]
2647 .as_str()
2648 .ok_or_else(|| anyhow!("self.slot_id missing"))?;
2649 let slot_token = self_state["slot_token"]
2650 .as_str()
2651 .ok_or_else(|| anyhow!("self.slot_token missing"))?;
2652 let last_event_id = self_state
2653 .get("last_pulled_event_id")
2654 .and_then(Value::as_str)
2655 .map(str::to_string);
2656
2657 let client = crate::relay_client::RelayClient::new(url);
2658 let events = client.list_events(slot_id, slot_token, last_event_id.as_deref(), Some(1000))?;
2659
2660 let inbox_dir = config::inbox_dir()?;
2661 config::ensure_dirs()?;
2662
2663 let result = crate::pull::process_events(&events, last_event_id, &inbox_dir)?;
2666
2667 if let Some(eid) = &result.advance_cursor_to {
2672 let eid = eid.clone();
2673 config::update_relay_state(|state| {
2674 if let Some(self_obj) = state.get_mut("self").and_then(Value::as_object_mut) {
2675 self_obj.insert("last_pulled_event_id".into(), Value::String(eid));
2676 }
2677 Ok(())
2678 })?;
2679 }
2680
2681 if as_json {
2682 println!(
2683 "{}",
2684 serde_json::to_string(&json!({
2685 "written": result.written,
2686 "rejected": result.rejected,
2687 "total_seen": events.len(),
2688 "cursor_blocked": result.blocked,
2689 "cursor_advanced_to": result.advance_cursor_to,
2690 }))?
2691 );
2692 } else {
2693 let blocking = result
2694 .rejected
2695 .iter()
2696 .filter(|r| r.get("blocks_cursor").and_then(Value::as_bool) == Some(true))
2697 .count();
2698 if blocking > 0 {
2699 println!(
2700 "pulled {} event(s); wrote {}; rejected {} ({} BLOCKING cursor — see `wire pull --json`)",
2701 events.len(),
2702 result.written.len(),
2703 result.rejected.len(),
2704 blocking,
2705 );
2706 } else {
2707 println!(
2708 "pulled {} event(s); wrote {}; rejected {}",
2709 events.len(),
2710 result.written.len(),
2711 result.rejected.len(),
2712 );
2713 }
2714 }
2715 Ok(())
2716}
2717
2718fn cmd_rotate_slot(no_announce: bool, as_json: bool) -> Result<()> {
2721 if !config::is_initialized()? {
2722 bail!("not initialized — run `wire init <handle>` first");
2723 }
2724 let mut state = config::read_relay_state()?;
2725 let self_state = state.get("self").cloned().unwrap_or(Value::Null);
2726 if self_state.is_null() {
2727 bail!("self slot not bound — run `wire bind-relay <url>` first (nothing to rotate)");
2728 }
2729 let url = self_state["relay_url"]
2730 .as_str()
2731 .ok_or_else(|| anyhow!("self.relay_url missing"))?
2732 .to_string();
2733 let old_slot_id = self_state["slot_id"]
2734 .as_str()
2735 .ok_or_else(|| anyhow!("self.slot_id missing"))?
2736 .to_string();
2737 let old_slot_token = self_state["slot_token"]
2738 .as_str()
2739 .ok_or_else(|| anyhow!("self.slot_token missing"))?
2740 .to_string();
2741
2742 let card = config::read_agent_card()?;
2744 let did = card
2745 .get("did")
2746 .and_then(Value::as_str)
2747 .unwrap_or("")
2748 .to_string();
2749 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
2750 let pk_b64 = card
2751 .get("verify_keys")
2752 .and_then(Value::as_object)
2753 .and_then(|m| m.values().next())
2754 .and_then(|v| v.get("key"))
2755 .and_then(Value::as_str)
2756 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?
2757 .to_string();
2758 let pk_bytes = crate::signing::b64decode(&pk_b64)?;
2759 let sk_seed = config::read_private_key()?;
2760
2761 let normalized = url.trim_end_matches('/').to_string();
2763 let client = crate::relay_client::RelayClient::new(&normalized);
2764 client
2765 .check_healthz()
2766 .context("aborting rotation; old slot still valid")?;
2767 let alloc = client.allocate_slot(Some(&handle))?;
2768 let new_slot_id = alloc.slot_id.clone();
2769 let new_slot_token = alloc.slot_token.clone();
2770
2771 let mut announced: Vec<String> = Vec::new();
2778 if !no_announce {
2779 let now = time::OffsetDateTime::now_utc()
2780 .format(&time::format_description::well_known::Rfc3339)
2781 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
2782 let body = json!({
2783 "reason": "operator-initiated slot rotation",
2784 "new_relay_url": url,
2785 "new_slot_id": new_slot_id,
2786 });
2790 let peers = state["peers"].as_object().cloned().unwrap_or_default();
2791 for (peer_handle, _peer_info) in peers.iter() {
2792 let event = json!({
2793 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
2794 "timestamp": now.clone(),
2795 "from": did,
2796 "to": format!("did:wire:{peer_handle}"),
2797 "type": "wire_close",
2798 "kind": 1201,
2799 "body": body.clone(),
2800 });
2801 let signed = match sign_message_v31(&event, &sk_seed, &pk_bytes, &handle) {
2802 Ok(s) => s,
2803 Err(e) => {
2804 eprintln!("warn: could not sign wire_close for {peer_handle}: {e}");
2805 continue;
2806 }
2807 };
2808 let peer_info = match state["peers"].get(peer_handle) {
2813 Some(p) => p.clone(),
2814 None => continue,
2815 };
2816 let peer_url = peer_info["relay_url"].as_str().unwrap_or(&url);
2817 let peer_slot_id = peer_info["slot_id"].as_str().unwrap_or("");
2818 let peer_slot_token = peer_info["slot_token"].as_str().unwrap_or("");
2819 if peer_slot_id.is_empty() || peer_slot_token.is_empty() {
2820 continue;
2821 }
2822 let peer_client = if peer_url == url {
2823 client.clone()
2824 } else {
2825 crate::relay_client::RelayClient::new(peer_url)
2826 };
2827 match peer_client.post_event(peer_slot_id, peer_slot_token, &signed) {
2828 Ok(_) => announced.push(peer_handle.clone()),
2829 Err(e) => eprintln!("warn: announce to {peer_handle} failed: {e}"),
2830 }
2831 }
2832 }
2833
2834 state["self"] = json!({
2836 "relay_url": url,
2837 "slot_id": new_slot_id,
2838 "slot_token": new_slot_token,
2839 });
2840 config::write_relay_state(&state)?;
2841
2842 if as_json {
2843 println!(
2844 "{}",
2845 serde_json::to_string(&json!({
2846 "rotated": true,
2847 "old_slot_id": old_slot_id,
2848 "new_slot_id": new_slot_id,
2849 "relay_url": url,
2850 "announced_to": announced,
2851 }))?
2852 );
2853 } else {
2854 println!("rotated slot on {url}");
2855 println!(
2856 " old slot_id: {old_slot_id} (orphaned — abusive bearer-holders lose their leverage)"
2857 );
2858 println!(" new slot_id: {new_slot_id}");
2859 if !announced.is_empty() {
2860 println!(
2861 " announced wire_close (kind=1201) to: {}",
2862 announced.join(", ")
2863 );
2864 }
2865 println!();
2866 println!("next steps:");
2867 println!(" - peers see the wire_close event in their next `wire pull`");
2868 println!(
2869 " - paired peers must re-issue: tell them to run `wire add-peer-slot {handle} {url} {new_slot_id} <new-token>`"
2870 );
2871 println!(" (or full re-pair via `wire pair-host`/`wire join`)");
2872 println!(" - until they do, you'll receive but they won't be able to reach you");
2873 let _ = old_slot_token;
2875 }
2876 Ok(())
2877}
2878
2879fn cmd_forget_peer(handle: &str, purge: bool, as_json: bool) -> Result<()> {
2882 let mut trust = config::read_trust()?;
2883 let mut removed_from_trust = false;
2884 if let Some(agents) = trust.get_mut("agents").and_then(Value::as_object_mut)
2885 && agents.remove(handle).is_some()
2886 {
2887 removed_from_trust = true;
2888 }
2889 config::write_trust(&trust)?;
2890
2891 let mut state = config::read_relay_state()?;
2892 let mut removed_from_relay = false;
2893 if let Some(peers) = state.get_mut("peers").and_then(Value::as_object_mut)
2894 && peers.remove(handle).is_some()
2895 {
2896 removed_from_relay = true;
2897 }
2898 config::write_relay_state(&state)?;
2899
2900 let mut purged: Vec<String> = Vec::new();
2901 if purge {
2902 for dir in [config::inbox_dir()?, config::outbox_dir()?] {
2903 let path = dir.join(format!("{handle}.jsonl"));
2904 if path.exists() {
2905 std::fs::remove_file(&path).with_context(|| format!("removing {path:?}"))?;
2906 purged.push(path.to_string_lossy().into());
2907 }
2908 }
2909 }
2910
2911 if !removed_from_trust && !removed_from_relay {
2912 if as_json {
2913 println!(
2914 "{}",
2915 serde_json::to_string(&json!({
2916 "removed": false,
2917 "reason": format!("peer {handle:?} not pinned"),
2918 }))?
2919 );
2920 } else {
2921 eprintln!("peer {handle:?} not found in trust or relay state — nothing to forget");
2922 }
2923 return Ok(());
2924 }
2925
2926 if as_json {
2927 println!(
2928 "{}",
2929 serde_json::to_string(&json!({
2930 "handle": handle,
2931 "removed_from_trust": removed_from_trust,
2932 "removed_from_relay_state": removed_from_relay,
2933 "purged_files": purged,
2934 }))?
2935 );
2936 } else {
2937 println!("forgot peer {handle:?}");
2938 if removed_from_trust {
2939 println!(" - removed from trust.json");
2940 }
2941 if removed_from_relay {
2942 println!(" - removed from relay.json");
2943 }
2944 if !purged.is_empty() {
2945 for p in &purged {
2946 println!(" - deleted {p}");
2947 }
2948 } else if !purge {
2949 println!(" (inbox/outbox files preserved; pass --purge to delete them)");
2950 }
2951 }
2952 Ok(())
2953}
2954
2955fn cmd_daemon(interval_secs: u64, once: bool, as_json: bool) -> Result<()> {
2958 if !config::is_initialized()? {
2959 bail!("not initialized — run `wire init <handle>` first");
2960 }
2961 let interval = std::time::Duration::from_secs(interval_secs.max(1));
2962
2963 if !as_json {
2964 if once {
2965 eprintln!("wire daemon: single sync cycle, then exit");
2966 } else {
2967 eprintln!("wire daemon: syncing every {interval_secs}s. SIGINT to stop.");
2968 }
2969 }
2970
2971 if let Err(e) = crate::pending_pair::cleanup_on_startup() {
2975 eprintln!("daemon: pending-pair cleanup_on_startup error: {e:#}");
2976 }
2977
2978 let (wake_tx, wake_rx) = std::sync::mpsc::channel::<()>();
2984 if !once {
2985 crate::daemon_stream::spawn_stream_subscriber(wake_tx);
2986 }
2987
2988 loop {
2989 let pushed = run_sync_push().unwrap_or_else(|e| {
2990 eprintln!("daemon: push error: {e:#}");
2991 json!({"pushed": [], "skipped": [{"error": e.to_string()}]})
2992 });
2993 let pulled = run_sync_pull().unwrap_or_else(|e| {
2994 eprintln!("daemon: pull error: {e:#}");
2995 json!({"written": [], "rejected": [], "total_seen": 0, "error": e.to_string()})
2996 });
2997 let pairs = crate::pending_pair::tick().unwrap_or_else(|e| {
2998 eprintln!("daemon: pending-pair tick error: {e:#}");
2999 json!({"transitions": []})
3000 });
3001
3002 if as_json {
3003 println!(
3004 "{}",
3005 serde_json::to_string(&json!({
3006 "ts": time::OffsetDateTime::now_utc()
3007 .format(&time::format_description::well_known::Rfc3339)
3008 .unwrap_or_default(),
3009 "push": pushed,
3010 "pull": pulled,
3011 "pairs": pairs,
3012 }))?
3013 );
3014 } else {
3015 let pushed_n = pushed["pushed"].as_array().map(|a| a.len()).unwrap_or(0);
3016 let written_n = pulled["written"].as_array().map(|a| a.len()).unwrap_or(0);
3017 let rejected_n = pulled["rejected"].as_array().map(|a| a.len()).unwrap_or(0);
3018 let pair_transitions = pairs["transitions"]
3019 .as_array()
3020 .map(|a| a.len())
3021 .unwrap_or(0);
3022 if pushed_n > 0 || written_n > 0 || rejected_n > 0 || pair_transitions > 0 {
3023 eprintln!(
3024 "daemon: pushed={pushed_n} pulled={written_n} rejected={rejected_n} pair-transitions={pair_transitions}"
3025 );
3026 }
3027 if let Some(arr) = pairs["transitions"].as_array() {
3029 for t in arr {
3030 eprintln!(
3031 " pair {} : {} → {}",
3032 t.get("code").and_then(Value::as_str).unwrap_or("?"),
3033 t.get("from").and_then(Value::as_str).unwrap_or("?"),
3034 t.get("to").and_then(Value::as_str).unwrap_or("?")
3035 );
3036 if let Some(sas) = t.get("sas").and_then(Value::as_str)
3037 && t.get("to").and_then(Value::as_str) == Some("sas_ready")
3038 {
3039 eprintln!(" SAS digits: {}-{}", &sas[..3], &sas[3..]);
3040 eprintln!(
3041 " Run: wire pair-confirm {} {}",
3042 t.get("code").and_then(Value::as_str).unwrap_or("?"),
3043 sas
3044 );
3045 }
3046 }
3047 }
3048 }
3049
3050 if once {
3051 return Ok(());
3052 }
3053 let _ = wake_rx.recv_timeout(interval);
3058 while wake_rx.try_recv().is_ok() {}
3059 }
3060}
3061
3062fn run_sync_push() -> Result<Value> {
3065 let state = config::read_relay_state()?;
3066 let peers = state["peers"].as_object().cloned().unwrap_or_default();
3067 if peers.is_empty() {
3068 return Ok(json!({"pushed": [], "skipped": []}));
3069 }
3070 let outbox_dir = config::outbox_dir()?;
3071 if !outbox_dir.exists() {
3072 return Ok(json!({"pushed": [], "skipped": []}));
3073 }
3074 let mut pushed = Vec::new();
3075 let mut skipped = Vec::new();
3076 for (peer_handle, slot_info) in peers.iter() {
3077 let outbox = outbox_dir.join(format!("{peer_handle}.jsonl"));
3078 if !outbox.exists() {
3079 continue;
3080 }
3081 let url = slot_info["relay_url"].as_str().unwrap_or("");
3082 let slot_id = slot_info["slot_id"].as_str().unwrap_or("");
3083 let slot_token = slot_info["slot_token"].as_str().unwrap_or("");
3084 if url.is_empty() || slot_id.is_empty() || slot_token.is_empty() {
3085 continue;
3086 }
3087 let client = crate::relay_client::RelayClient::new(url);
3088 let body = std::fs::read_to_string(&outbox)?;
3089 for line in body.lines() {
3090 let event: Value = match serde_json::from_str(line) {
3091 Ok(v) => v,
3092 Err(_) => continue,
3093 };
3094 let event_id = event
3095 .get("event_id")
3096 .and_then(Value::as_str)
3097 .unwrap_or("")
3098 .to_string();
3099 match client.post_event(slot_id, slot_token, &event) {
3100 Ok(resp) => {
3101 if resp.status == "duplicate" {
3102 skipped.push(json!({"peer": peer_handle, "event_id": event_id, "reason": "duplicate"}));
3103 } else {
3104 pushed.push(json!({"peer": peer_handle, "event_id": event_id}));
3105 }
3106 }
3107 Err(e) => {
3108 let reason = crate::relay_client::format_transport_error(&e);
3112 skipped.push(
3113 json!({"peer": peer_handle, "event_id": event_id, "reason": reason}),
3114 );
3115 }
3116 }
3117 }
3118 }
3119 Ok(json!({"pushed": pushed, "skipped": skipped}))
3120}
3121
3122fn run_sync_pull() -> Result<Value> {
3124 let state = config::read_relay_state()?;
3125 let self_state = state.get("self").cloned().unwrap_or(Value::Null);
3126 if self_state.is_null() {
3127 return Ok(json!({"written": [], "rejected": [], "total_seen": 0}));
3128 }
3129 let url = self_state["relay_url"].as_str().unwrap_or("");
3130 let slot_id = self_state["slot_id"].as_str().unwrap_or("");
3131 let slot_token = self_state["slot_token"].as_str().unwrap_or("");
3132 let last_event_id = self_state
3133 .get("last_pulled_event_id")
3134 .and_then(Value::as_str)
3135 .map(str::to_string);
3136 if url.is_empty() {
3137 return Ok(json!({"written": [], "rejected": [], "total_seen": 0}));
3138 }
3139 let client = crate::relay_client::RelayClient::new(url);
3140 let events = client.list_events(slot_id, slot_token, last_event_id.as_deref(), Some(1000))?;
3141 let inbox_dir = config::inbox_dir()?;
3142 config::ensure_dirs()?;
3143
3144 let result = crate::pull::process_events(&events, last_event_id, &inbox_dir)?;
3148
3149 if let Some(eid) = &result.advance_cursor_to {
3151 let eid = eid.clone();
3152 config::update_relay_state(|state| {
3153 if let Some(self_obj) = state.get_mut("self").and_then(Value::as_object_mut) {
3154 self_obj.insert("last_pulled_event_id".into(), Value::String(eid));
3155 }
3156 Ok(())
3157 })?;
3158 }
3159
3160 Ok(json!({
3161 "written": result.written,
3162 "rejected": result.rejected,
3163 "total_seen": events.len(),
3164 "cursor_blocked": result.blocked,
3165 "cursor_advanced_to": result.advance_cursor_to,
3166 }))
3167}
3168
3169fn cmd_pin(card_file: &str, as_json: bool) -> Result<()> {
3172 let body =
3173 std::fs::read_to_string(card_file).with_context(|| format!("reading {card_file}"))?;
3174 let card: Value =
3175 serde_json::from_str(&body).with_context(|| format!("parsing {card_file}"))?;
3176 crate::agent_card::verify_agent_card(&card)
3177 .map_err(|e| anyhow!("peer card signature invalid: {e}"))?;
3178
3179 let mut trust = config::read_trust()?;
3180 crate::trust::add_agent_card_pin(&mut trust, &card, Some("VERIFIED"));
3181
3182 let did = card.get("did").and_then(Value::as_str).unwrap_or("");
3183 let handle = crate::agent_card::display_handle_from_did(did).to_string();
3184 config::write_trust(&trust)?;
3185
3186 if as_json {
3187 println!(
3188 "{}",
3189 serde_json::to_string(&json!({
3190 "handle": handle,
3191 "did": did,
3192 "tier": "VERIFIED",
3193 "pinned": true,
3194 }))?
3195 );
3196 } else {
3197 println!("pinned {handle} ({did}) at tier VERIFIED");
3198 }
3199 Ok(())
3200}
3201
3202fn cmd_pair_host(relay_url: &str, auto_yes: bool, timeout_secs: u64) -> Result<()> {
3205 pair_orchestrate(relay_url, None, "host", auto_yes, timeout_secs)
3206}
3207
3208fn cmd_pair_join(
3209 code_phrase: &str,
3210 relay_url: &str,
3211 auto_yes: bool,
3212 timeout_secs: u64,
3213) -> Result<()> {
3214 pair_orchestrate(
3215 relay_url,
3216 Some(code_phrase),
3217 "guest",
3218 auto_yes,
3219 timeout_secs,
3220 )
3221}
3222
3223fn pair_orchestrate(
3229 relay_url: &str,
3230 code_in: Option<&str>,
3231 role: &str,
3232 auto_yes: bool,
3233 timeout_secs: u64,
3234) -> Result<()> {
3235 use crate::pair_session::{pair_session_finalize, pair_session_open, pair_session_try_sas};
3236
3237 let mut s = pair_session_open(role, relay_url, code_in)?;
3238
3239 if role == "host" {
3240 eprintln!();
3241 eprintln!("share this code phrase with your peer:");
3242 eprintln!();
3243 eprintln!(" {}", s.code);
3244 eprintln!();
3245 eprintln!(
3246 "waiting for peer to run `wire pair-join {} --relay {relay_url}` ...",
3247 s.code
3248 );
3249 } else {
3250 eprintln!();
3251 eprintln!("joined pair-slot on {relay_url} — waiting for host's SPAKE2 message ...");
3252 }
3253
3254 const HEARTBEAT_SECS: u64 = 10;
3259 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
3260 let started = std::time::Instant::now();
3261 let mut last_heartbeat = started;
3262 let formatted = loop {
3263 if let Some(sas) = pair_session_try_sas(&mut s)? {
3264 break sas;
3265 }
3266 let now = std::time::Instant::now();
3267 if now >= deadline {
3268 return Err(anyhow!(
3269 "timeout after {timeout_secs}s waiting for peer's SPAKE2 message"
3270 ));
3271 }
3272 if now.duration_since(last_heartbeat).as_secs() >= HEARTBEAT_SECS {
3273 let elapsed = now.duration_since(started).as_secs();
3274 eprintln!(" ... still waiting ({elapsed}s / {timeout_secs}s)");
3275 last_heartbeat = now;
3276 }
3277 std::thread::sleep(std::time::Duration::from_millis(250));
3278 };
3279
3280 eprintln!();
3281 eprintln!("SAS digits (must match peer's terminal):");
3282 eprintln!();
3283 eprintln!(" {formatted}");
3284 eprintln!();
3285
3286 if !auto_yes {
3289 eprint!("does this match your peer's terminal? [y/N]: ");
3290 use std::io::Write;
3291 std::io::stderr().flush().ok();
3292 let mut input = String::new();
3293 std::io::stdin().read_line(&mut input)?;
3294 let trimmed = input.trim().to_lowercase();
3295 if trimmed != "y" && trimmed != "yes" {
3296 bail!("SAS confirmation declined — aborting pairing");
3297 }
3298 }
3299 s.sas_confirmed = true;
3300
3301 let result = pair_session_finalize(&mut s, timeout_secs)?;
3303
3304 let peer_did = result["paired_with"].as_str().unwrap_or("");
3305 let peer_role = if role == "host" { "guest" } else { "host" };
3306 eprintln!("paired with {peer_did} (peer role: {peer_role})");
3307 eprintln!("peer card pinned at tier VERIFIED");
3308 eprintln!(
3309 "peer relay slot saved to {}",
3310 config::relay_state_path()?.display()
3311 );
3312
3313 println!("{}", serde_json::to_string(&result)?);
3314 Ok(())
3315}
3316
3317fn cmd_pair(
3323 handle: &str,
3324 code: Option<&str>,
3325 relay: &str,
3326 auto_yes: bool,
3327 timeout_secs: u64,
3328 no_setup: bool,
3329) -> Result<()> {
3330 let init_result = crate::pair_session::init_self_idempotent(handle, None, None)?;
3333 let did = init_result
3334 .get("did")
3335 .and_then(|v| v.as_str())
3336 .unwrap_or("(unknown)")
3337 .to_string();
3338 let already = init_result
3339 .get("already_initialized")
3340 .and_then(|v| v.as_bool())
3341 .unwrap_or(false);
3342 if already {
3343 println!("(identity {did} already initialized — reusing)");
3344 } else {
3345 println!("initialized {did}");
3346 }
3347 println!();
3348
3349 match code {
3351 None => {
3352 println!("hosting pair on {relay} (no code = host) ...");
3353 cmd_pair_host(relay, auto_yes, timeout_secs)?;
3354 }
3355 Some(c) => {
3356 println!("joining pair with code {c} on {relay} ...");
3357 cmd_pair_join(c, relay, auto_yes, timeout_secs)?;
3358 }
3359 }
3360
3361 if !no_setup {
3363 println!();
3364 println!("registering wire as MCP server in detected client configs ...");
3365 if let Err(e) = cmd_setup(true) {
3366 eprintln!("warn: setup --apply failed: {e}");
3368 eprintln!(" pair succeeded; you can re-run `wire setup --apply` manually.");
3369 }
3370 }
3371
3372 println!();
3373 println!("pair complete. Next steps:");
3374 println!(" wire daemon start # background sync of inbox/outbox vs relay");
3375 println!(" wire send <peer> claim <msg> # send your peer something");
3376 println!(" wire tail # watch incoming events");
3377 Ok(())
3378}
3379
3380fn cmd_pair_detach(handle: &str, code: Option<&str>, relay: &str) -> Result<()> {
3386 let init_result = crate::pair_session::init_self_idempotent(handle, None, None)?;
3387 let did = init_result
3388 .get("did")
3389 .and_then(|v| v.as_str())
3390 .unwrap_or("(unknown)")
3391 .to_string();
3392 let already = init_result
3393 .get("already_initialized")
3394 .and_then(|v| v.as_bool())
3395 .unwrap_or(false);
3396 if already {
3397 println!("(identity {did} already initialized — reusing)");
3398 } else {
3399 println!("initialized {did}");
3400 }
3401 println!();
3402 match code {
3403 None => cmd_pair_host_detach(relay, false),
3404 Some(c) => cmd_pair_join_detach(c, relay, false),
3405 }
3406}
3407
3408fn cmd_pair_host_detach(relay_url: &str, as_json: bool) -> Result<()> {
3409 if !config::is_initialized()? {
3410 bail!("not initialized — run `wire init <handle>` first");
3411 }
3412 let daemon_spawned = match crate::ensure_up::ensure_daemon_running() {
3413 Ok(b) => b,
3414 Err(e) => {
3415 if !as_json {
3416 eprintln!(
3417 "warn: could not auto-start daemon: {e}; pair will queue but not advance"
3418 );
3419 }
3420 false
3421 }
3422 };
3423 let code = crate::sas::generate_code_phrase();
3424 let code_hash = crate::pair_session::derive_code_hash(&code);
3425 let now = time::OffsetDateTime::now_utc()
3426 .format(&time::format_description::well_known::Rfc3339)
3427 .unwrap_or_default();
3428 let p = crate::pending_pair::PendingPair {
3429 code: code.clone(),
3430 code_hash,
3431 role: "host".to_string(),
3432 relay_url: relay_url.to_string(),
3433 status: "request_host".to_string(),
3434 sas: None,
3435 peer_did: None,
3436 created_at: now,
3437 last_error: None,
3438 pair_id: None,
3439 our_slot_id: None,
3440 our_slot_token: None,
3441 spake2_seed_b64: None,
3442 };
3443 crate::pending_pair::write_pending(&p)?;
3444 if as_json {
3445 println!(
3446 "{}",
3447 serde_json::to_string(&json!({
3448 "state": "queued",
3449 "code_phrase": code,
3450 "relay_url": relay_url,
3451 "role": "host",
3452 "daemon_spawned": daemon_spawned,
3453 }))?
3454 );
3455 } else {
3456 if daemon_spawned {
3457 println!("(started wire daemon in background)");
3458 }
3459 println!("detached pair-host queued. Share this code with your peer:\n");
3460 println!(" {code}\n");
3461 println!("Next steps:");
3462 println!(" wire pair-list # check status");
3463 println!(" wire pair-confirm {code} <digits> # when SAS shows up");
3464 println!(" wire pair-cancel {code} # to abort");
3465 }
3466 Ok(())
3467}
3468
3469fn cmd_pair_join_detach(code_phrase: &str, relay_url: &str, as_json: bool) -> Result<()> {
3470 if !config::is_initialized()? {
3471 bail!("not initialized — run `wire init <handle>` first");
3472 }
3473 let daemon_spawned = match crate::ensure_up::ensure_daemon_running() {
3474 Ok(b) => b,
3475 Err(e) => {
3476 if !as_json {
3477 eprintln!(
3478 "warn: could not auto-start daemon: {e}; pair will queue but not advance"
3479 );
3480 }
3481 false
3482 }
3483 };
3484 let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
3485 let code_hash = crate::pair_session::derive_code_hash(&code);
3486 let now = time::OffsetDateTime::now_utc()
3487 .format(&time::format_description::well_known::Rfc3339)
3488 .unwrap_or_default();
3489 let p = crate::pending_pair::PendingPair {
3490 code: code.clone(),
3491 code_hash,
3492 role: "guest".to_string(),
3493 relay_url: relay_url.to_string(),
3494 status: "request_guest".to_string(),
3495 sas: None,
3496 peer_did: None,
3497 created_at: now,
3498 last_error: None,
3499 pair_id: None,
3500 our_slot_id: None,
3501 our_slot_token: None,
3502 spake2_seed_b64: None,
3503 };
3504 crate::pending_pair::write_pending(&p)?;
3505 if as_json {
3506 println!(
3507 "{}",
3508 serde_json::to_string(&json!({
3509 "state": "queued",
3510 "code_phrase": code,
3511 "relay_url": relay_url,
3512 "role": "guest",
3513 "daemon_spawned": daemon_spawned,
3514 }))?
3515 );
3516 } else {
3517 if daemon_spawned {
3518 println!("(started wire daemon in background)");
3519 }
3520 println!("detached pair-join queued for code {code}.");
3521 println!(
3522 "Run `wire pair-list` to watch for SAS, then `wire pair-confirm {code} <digits>`."
3523 );
3524 }
3525 Ok(())
3526}
3527
3528fn cmd_pair_confirm(code_phrase: &str, typed_digits: &str, as_json: bool) -> Result<()> {
3529 let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
3530 let typed: String = typed_digits
3531 .chars()
3532 .filter(|c| c.is_ascii_digit())
3533 .collect();
3534 if typed.len() != 6 {
3535 bail!(
3536 "expected 6 digits (got {} after stripping non-digits)",
3537 typed.len()
3538 );
3539 }
3540 let mut p = crate::pending_pair::read_pending(&code)?
3541 .ok_or_else(|| anyhow!("no pending pair found for code {code}"))?;
3542 if p.status != "sas_ready" {
3543 bail!(
3544 "pair {code} not in sas_ready state (current: {}). Run `wire pair-list` to see what's going on.",
3545 p.status
3546 );
3547 }
3548 let stored = p
3549 .sas
3550 .as_ref()
3551 .ok_or_else(|| anyhow!("pending file has status=sas_ready but no sas field"))?
3552 .clone();
3553 if stored == typed {
3554 p.status = "confirmed".to_string();
3555 crate::pending_pair::write_pending(&p)?;
3556 if as_json {
3557 println!(
3558 "{}",
3559 serde_json::to_string(&json!({
3560 "state": "confirmed",
3561 "code_phrase": code,
3562 }))?
3563 );
3564 } else {
3565 println!("digits match. Daemon will finalize the handshake on its next tick.");
3566 println!("Run `wire peers` after a few seconds to confirm.");
3567 }
3568 } else {
3569 p.status = "aborted".to_string();
3570 p.last_error = Some(format!(
3571 "SAS digit mismatch (typed {typed}, expected {stored})"
3572 ));
3573 let client = crate::relay_client::RelayClient::new(&p.relay_url);
3574 let _ = client.pair_abandon(&p.code_hash);
3575 crate::pending_pair::write_pending(&p)?;
3576 crate::os_notify::toast(
3577 &format!("wire — pair aborted ({})", p.code),
3578 p.last_error.as_deref().unwrap_or("digits mismatch"),
3579 );
3580 if as_json {
3581 println!(
3582 "{}",
3583 serde_json::to_string(&json!({
3584 "state": "aborted",
3585 "code_phrase": code,
3586 "error": "digits mismatch",
3587 }))?
3588 );
3589 }
3590 bail!("digits mismatch — pair aborted. Re-issue with a fresh `wire pair-host --detach`.");
3591 }
3592 Ok(())
3593}
3594
3595fn cmd_pair_list(as_json: bool, watch: bool, watch_interval_secs: u64) -> Result<()> {
3596 if watch {
3597 return cmd_pair_list_watch(watch_interval_secs);
3598 }
3599 let spake2_items = crate::pending_pair::list_pending()?;
3600 let inbound_items = crate::pending_inbound_pair::list_pending_inbound()?;
3601 if as_json {
3602 println!("{}", serde_json::to_string(&spake2_items)?);
3607 return Ok(());
3608 }
3609 if spake2_items.is_empty() && inbound_items.is_empty() {
3610 println!("no pending pair sessions.");
3611 return Ok(());
3612 }
3613 if !inbound_items.is_empty() {
3616 println!("PENDING INBOUND (v0.5.14 zero-paste pair_drop awaiting your accept)");
3617 println!(
3618 "{:<20} {:<35} {:<25} NEXT STEP",
3619 "PEER", "RELAY", "RECEIVED"
3620 );
3621 for p in &inbound_items {
3622 println!(
3623 "{:<20} {:<35} {:<25} `wire pair-accept {peer}` to accept; `wire pair-reject {peer}` to refuse",
3624 p.peer_handle,
3625 p.peer_relay_url,
3626 p.received_at,
3627 peer = p.peer_handle,
3628 );
3629 }
3630 println!();
3631 }
3632 if !spake2_items.is_empty() {
3633 println!("SPAKE2 SESSIONS");
3634 println!(
3635 "{:<15} {:<8} {:<18} {:<10} NOTE",
3636 "CODE", "ROLE", "STATUS", "SAS"
3637 );
3638 for p in spake2_items {
3639 let sas = p
3640 .sas
3641 .as_ref()
3642 .map(|d| format!("{}-{}", &d[..3], &d[3..]))
3643 .unwrap_or_else(|| "—".to_string());
3644 let note = p
3645 .last_error
3646 .as_deref()
3647 .or(p.peer_did.as_deref())
3648 .unwrap_or("");
3649 println!(
3650 "{:<15} {:<8} {:<18} {:<10} {}",
3651 p.code, p.role, p.status, sas, note
3652 );
3653 }
3654 }
3655 Ok(())
3656}
3657
3658fn cmd_pair_list_watch(interval_secs: u64) -> Result<()> {
3670 use std::collections::HashMap;
3671 use std::io::Write;
3672 let interval = std::time::Duration::from_secs(interval_secs.max(1));
3673 let mut prev: HashMap<String, String> = HashMap::new();
3676 {
3677 let items = crate::pending_pair::list_pending()?;
3678 for p in &items {
3679 println!("{}", serde_json::to_string(&p)?);
3680 prev.insert(p.code.clone(), p.status.clone());
3681 }
3682 let _ = std::io::stdout().flush();
3684 }
3685 loop {
3686 std::thread::sleep(interval);
3687 let items = match crate::pending_pair::list_pending() {
3688 Ok(v) => v,
3689 Err(_) => continue,
3690 };
3691 let mut cur: HashMap<String, String> = HashMap::new();
3692 for p in &items {
3693 cur.insert(p.code.clone(), p.status.clone());
3694 match prev.get(&p.code) {
3695 None => {
3696 println!("{}", serde_json::to_string(&p)?);
3698 }
3699 Some(prev_status) if prev_status != &p.status => {
3700 println!("{}", serde_json::to_string(&p)?);
3702 }
3703 _ => {}
3704 }
3705 }
3706 for code in prev.keys() {
3707 if !cur.contains_key(code) {
3708 println!(
3711 "{}",
3712 serde_json::to_string(&json!({
3713 "code": code,
3714 "status": "removed",
3715 "_synthetic": true,
3716 }))?
3717 );
3718 }
3719 }
3720 let _ = std::io::stdout().flush();
3721 prev = cur;
3722 }
3723}
3724
3725fn cmd_pair_watch(
3729 code_phrase: &str,
3730 target_status: &str,
3731 timeout_secs: u64,
3732 as_json: bool,
3733) -> Result<()> {
3734 let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
3735 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
3736 let mut last_seen_status: Option<String> = None;
3737 loop {
3738 let p_opt = crate::pending_pair::read_pending(&code)?;
3739 let now = std::time::Instant::now();
3740 match p_opt {
3741 None => {
3742 if last_seen_status.is_some() {
3746 if as_json {
3747 println!(
3748 "{}",
3749 serde_json::to_string(&json!({"state": "finalized", "code": code}))?
3750 );
3751 } else {
3752 println!("pair {code} finalized (file removed)");
3753 }
3754 return Ok(());
3755 } else {
3756 if as_json {
3757 println!(
3758 "{}",
3759 serde_json::to_string(&json!({"error": "no such pair", "code": code}))?
3760 );
3761 }
3762 std::process::exit(1);
3763 }
3764 }
3765 Some(p) => {
3766 let cur = p.status.clone();
3767 if Some(cur.clone()) != last_seen_status {
3768 if as_json {
3769 println!("{}", serde_json::to_string(&p)?);
3771 }
3772 last_seen_status = Some(cur.clone());
3773 }
3774 if cur == target_status {
3775 if !as_json {
3776 let sas_str = p
3777 .sas
3778 .as_ref()
3779 .map(|s| format!("{}-{}", &s[..3], &s[3..]))
3780 .unwrap_or_else(|| "—".to_string());
3781 println!("pair {code} reached {target_status} (SAS: {sas_str})");
3782 }
3783 return Ok(());
3784 }
3785 if cur == "aborted" || cur == "aborted_restart" {
3786 if !as_json {
3787 let err = p.last_error.as_deref().unwrap_or("(no detail)");
3788 eprintln!("pair {code} {cur}: {err}");
3789 }
3790 std::process::exit(1);
3791 }
3792 }
3793 }
3794 if now >= deadline {
3795 if !as_json {
3796 eprintln!(
3797 "timeout after {timeout_secs}s waiting for pair {code} to reach {target_status}"
3798 );
3799 }
3800 std::process::exit(2);
3801 }
3802 std::thread::sleep(std::time::Duration::from_millis(250));
3803 }
3804}
3805
3806fn cmd_pair_cancel(code_phrase: &str, as_json: bool) -> Result<()> {
3807 let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
3808 let p = crate::pending_pair::read_pending(&code)?
3809 .ok_or_else(|| anyhow!("no pending pair for code {code}"))?;
3810 let client = crate::relay_client::RelayClient::new(&p.relay_url);
3811 let _ = client.pair_abandon(&p.code_hash);
3812 crate::pending_pair::delete_pending(&code)?;
3813 if as_json {
3814 println!(
3815 "{}",
3816 serde_json::to_string(&json!({
3817 "state": "cancelled",
3818 "code_phrase": code,
3819 }))?
3820 );
3821 } else {
3822 println!("cancelled pending pair {code} (relay slot released, file removed).");
3823 }
3824 Ok(())
3825}
3826
3827fn cmd_pair_abandon(code_phrase: &str, relay_url: &str) -> Result<()> {
3830 let code = crate::sas::parse_code_phrase(code_phrase)?;
3833 let code_hash = crate::pair_session::derive_code_hash(code);
3834 let client = crate::relay_client::RelayClient::new(relay_url);
3835 client.pair_abandon(&code_hash)?;
3836 println!("abandoned pair-slot for code {code_phrase} on {relay_url}");
3837 println!("host can now issue a fresh code; guest can re-join.");
3838 Ok(())
3839}
3840
3841fn cmd_invite(relay: &str, ttl: u64, uses: u32, share: bool, as_json: bool) -> Result<()> {
3844 let url = crate::pair_invite::mint_invite(Some(ttl), uses, Some(relay))?;
3845
3846 let share_payload: Option<Value> = if share {
3849 let client = reqwest::blocking::Client::new();
3850 let single_use = if uses == 1 { Some(1u32) } else { None };
3851 let body = json!({
3852 "invite_url": url,
3853 "ttl_seconds": ttl,
3854 "uses": single_use,
3855 });
3856 let endpoint = format!("{}/v1/invite/register", relay.trim_end_matches('/'));
3857 let resp = client.post(&endpoint).json(&body).send()?;
3858 if !resp.status().is_success() {
3859 let code = resp.status();
3860 let txt = resp.text().unwrap_or_default();
3861 bail!("relay {code} on /v1/invite/register: {txt}");
3862 }
3863 let parsed: Value = resp.json()?;
3864 let token = parsed
3865 .get("token")
3866 .and_then(Value::as_str)
3867 .ok_or_else(|| anyhow::anyhow!("relay reply missing token"))?
3868 .to_string();
3869 let share_url = format!("{}/i/{}", relay.trim_end_matches('/'), token);
3870 let curl_line = format!("curl -fsSL {share_url} | sh");
3871 Some(json!({
3872 "token": token,
3873 "share_url": share_url,
3874 "curl": curl_line,
3875 "expires_unix": parsed.get("expires_unix"),
3876 }))
3877 } else {
3878 None
3879 };
3880
3881 if as_json {
3882 let mut out = json!({
3883 "invite_url": url,
3884 "ttl_secs": ttl,
3885 "uses": uses,
3886 "relay": relay,
3887 });
3888 if let Some(s) = &share_payload {
3889 out["share"] = s.clone();
3890 }
3891 println!("{}", serde_json::to_string(&out)?);
3892 } else if let Some(s) = share_payload {
3893 let curl = s.get("curl").and_then(Value::as_str).unwrap_or("");
3894 eprintln!("# One-curl onboarding. Share this single line — installs wire if missing,");
3895 eprintln!("# accepts the invite, pairs both sides. TTL: {ttl}s. Uses: {uses}.");
3896 println!("{curl}");
3897 } else {
3898 eprintln!("# Share this URL with one peer. Pasting it = pair complete on their side.");
3899 eprintln!("# TTL: {ttl}s. Uses: {uses}.");
3900 println!("{url}");
3901 }
3902 Ok(())
3903}
3904
3905fn cmd_accept(url: &str, as_json: bool) -> Result<()> {
3906 let resolved = if url.starts_with("http://") || url.starts_with("https://") {
3910 let sep = if url.contains('?') { '&' } else { '?' };
3911 let resolve_url = format!("{url}{sep}format=url");
3912 let client = reqwest::blocking::Client::new();
3913 let resp = client
3914 .get(&resolve_url)
3915 .send()
3916 .with_context(|| format!("GET {resolve_url}"))?;
3917 if !resp.status().is_success() {
3918 bail!("could not resolve short URL {url} (HTTP {})", resp.status());
3919 }
3920 let body = resp.text().unwrap_or_default().trim().to_string();
3921 if !body.starts_with("wire://pair?") {
3922 bail!(
3923 "short URL {url} did not resolve to a wire:// invite. \
3924 (got: {}{})",
3925 body.chars().take(80).collect::<String>(),
3926 if body.chars().count() > 80 { "…" } else { "" }
3927 );
3928 }
3929 body
3930 } else {
3931 url.to_string()
3932 };
3933
3934 let result = crate::pair_invite::accept_invite(&resolved)?;
3935 if as_json {
3936 println!("{}", serde_json::to_string(&result)?);
3937 } else {
3938 let did = result
3939 .get("paired_with")
3940 .and_then(Value::as_str)
3941 .unwrap_or("?");
3942 println!("paired with {did}");
3943 println!(
3944 "you can now: wire send {} <kind> <body>",
3945 crate::agent_card::display_handle_from_did(did)
3946 );
3947 }
3948 Ok(())
3949}
3950
3951fn cmd_whois(handle: Option<&str>, as_json: bool, relay_override: Option<&str>) -> Result<()> {
3954 if let Some(h) = handle {
3955 let parsed = crate::pair_profile::parse_handle(h)?;
3956 if config::is_initialized()? {
3959 let card = config::read_agent_card()?;
3960 let local_handle = card
3961 .get("profile")
3962 .and_then(|p| p.get("handle"))
3963 .and_then(Value::as_str)
3964 .map(str::to_string);
3965 if local_handle.as_deref() == Some(h) {
3966 return cmd_whois(None, as_json, None);
3967 }
3968 }
3969 let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)?;
3971 if as_json {
3972 println!("{}", serde_json::to_string(&resolved)?);
3973 } else {
3974 print_resolved_profile(&resolved);
3975 }
3976 return Ok(());
3977 }
3978 let card = config::read_agent_card()?;
3979 if as_json {
3980 let profile = card.get("profile").cloned().unwrap_or(Value::Null);
3981 println!(
3982 "{}",
3983 serde_json::to_string(&json!({
3984 "did": card.get("did").cloned().unwrap_or(Value::Null),
3985 "profile": profile,
3986 }))?
3987 );
3988 } else {
3989 print!("{}", crate::pair_profile::render_self_summary()?);
3990 }
3991 Ok(())
3992}
3993
3994fn print_resolved_profile(resolved: &Value) {
3995 let did = resolved.get("did").and_then(Value::as_str).unwrap_or("?");
3996 let nick = resolved.get("nick").and_then(Value::as_str).unwrap_or("?");
3997 let relay = resolved
3998 .get("relay_url")
3999 .and_then(Value::as_str)
4000 .unwrap_or("");
4001 let slot = resolved
4002 .get("slot_id")
4003 .and_then(Value::as_str)
4004 .unwrap_or("");
4005 let profile = resolved
4006 .get("card")
4007 .and_then(|c| c.get("profile"))
4008 .cloned()
4009 .unwrap_or(Value::Null);
4010 println!("{did}");
4011 println!(" nick: {nick}");
4012 if !relay.is_empty() {
4013 println!(" relay_url: {relay}");
4014 }
4015 if !slot.is_empty() {
4016 println!(" slot_id: {slot}");
4017 }
4018 let pick =
4019 |k: &str| -> Option<String> { profile.get(k).and_then(Value::as_str).map(str::to_string) };
4020 if let Some(s) = pick("display_name") {
4021 println!(" display_name: {s}");
4022 }
4023 if let Some(s) = pick("emoji") {
4024 println!(" emoji: {s}");
4025 }
4026 if let Some(s) = pick("motto") {
4027 println!(" motto: {s}");
4028 }
4029 if let Some(arr) = profile.get("vibe").and_then(Value::as_array) {
4030 let joined: Vec<String> = arr
4031 .iter()
4032 .filter_map(|v| v.as_str().map(str::to_string))
4033 .collect();
4034 println!(" vibe: {}", joined.join(", "));
4035 }
4036 if let Some(s) = pick("pronouns") {
4037 println!(" pronouns: {s}");
4038 }
4039}
4040
4041fn cmd_add(handle_arg: &str, relay_override: Option<&str>, as_json: bool) -> Result<()> {
4047 let parsed = crate::pair_profile::parse_handle(handle_arg)?;
4048
4049 let (our_did, our_relay, our_slot_id, our_slot_token) =
4051 crate::pair_invite::ensure_self_with_relay(relay_override)?;
4052 if our_did == format!("did:wire:{}", parsed.nick) {
4053 bail!("refusing to add self (handle matches own DID)");
4055 }
4056
4057 if let Some(pending) = crate::pending_inbound_pair::read_pending_inbound(&parsed.nick)? {
4067 return cmd_add_accept_pending(
4068 handle_arg,
4069 &parsed.nick,
4070 &pending,
4071 &our_relay,
4072 &our_slot_id,
4073 &our_slot_token,
4074 as_json,
4075 );
4076 }
4077
4078 let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)?;
4080 let peer_card = resolved
4081 .get("card")
4082 .cloned()
4083 .ok_or_else(|| anyhow!("resolved missing card"))?;
4084 let peer_did = resolved
4085 .get("did")
4086 .and_then(Value::as_str)
4087 .ok_or_else(|| anyhow!("resolved missing did"))?
4088 .to_string();
4089 let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
4090 let peer_slot_id = resolved
4091 .get("slot_id")
4092 .and_then(Value::as_str)
4093 .ok_or_else(|| anyhow!("resolved missing slot_id"))?
4094 .to_string();
4095 let peer_relay = resolved
4096 .get("relay_url")
4097 .and_then(Value::as_str)
4098 .map(str::to_string)
4099 .or_else(|| relay_override.map(str::to_string))
4100 .unwrap_or_else(|| format!("https://{}", parsed.domain));
4101
4102 let mut trust = config::read_trust()?;
4104 crate::trust::add_agent_card_pin(&mut trust, &peer_card, Some("VERIFIED"));
4105 config::write_trust(&trust)?;
4106 let mut relay_state = config::read_relay_state()?;
4107 let existing_token = relay_state
4108 .get("peers")
4109 .and_then(|p| p.get(&peer_handle))
4110 .and_then(|p| p.get("slot_token"))
4111 .and_then(Value::as_str)
4112 .map(str::to_string)
4113 .unwrap_or_default();
4114 relay_state["peers"][&peer_handle] = json!({
4115 "relay_url": peer_relay,
4116 "slot_id": peer_slot_id,
4117 "slot_token": existing_token, });
4119 config::write_relay_state(&relay_state)?;
4120
4121 let our_card = config::read_agent_card()?;
4124 let sk_seed = config::read_private_key()?;
4125 let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
4126 let pk_b64 = our_card
4127 .get("verify_keys")
4128 .and_then(Value::as_object)
4129 .and_then(|m| m.values().next())
4130 .and_then(|v| v.get("key"))
4131 .and_then(Value::as_str)
4132 .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
4133 let pk_bytes = crate::signing::b64decode(pk_b64)?;
4134 let now = time::OffsetDateTime::now_utc()
4135 .format(&time::format_description::well_known::Rfc3339)
4136 .unwrap_or_default();
4137 let event = json!({
4138 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
4139 "timestamp": now,
4140 "from": our_did,
4141 "to": peer_did,
4142 "type": "pair_drop",
4143 "kind": 1100u32,
4144 "body": {
4145 "card": our_card,
4146 "relay_url": our_relay,
4147 "slot_id": our_slot_id,
4148 "slot_token": our_slot_token,
4149 },
4150 });
4151 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
4152
4153 let client = crate::relay_client::RelayClient::new(&peer_relay);
4155 let resp = client.handle_intro(&parsed.nick, &signed)?;
4156 let event_id = signed
4157 .get("event_id")
4158 .and_then(Value::as_str)
4159 .unwrap_or("")
4160 .to_string();
4161
4162 if as_json {
4163 println!(
4164 "{}",
4165 serde_json::to_string(&json!({
4166 "handle": handle_arg,
4167 "paired_with": peer_did,
4168 "peer_handle": peer_handle,
4169 "event_id": event_id,
4170 "drop_response": resp,
4171 "status": "drop_sent",
4172 }))?
4173 );
4174 } else {
4175 println!(
4176 "→ 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."
4177 );
4178 }
4179 Ok(())
4180}
4181
4182fn cmd_add_accept_pending(
4189 handle_arg: &str,
4190 peer_nick: &str,
4191 pending: &crate::pending_inbound_pair::PendingInboundPair,
4192 _our_relay: &str,
4193 _our_slot_id: &str,
4194 _our_slot_token: &str,
4195 as_json: bool,
4196) -> Result<()> {
4197 let mut trust = config::read_trust()?;
4200 crate::trust::add_agent_card_pin(&mut trust, &pending.peer_card, Some("VERIFIED"));
4201 config::write_trust(&trust)?;
4202
4203 let mut relay_state = config::read_relay_state()?;
4206 relay_state["peers"][&pending.peer_handle] = json!({
4207 "relay_url": pending.peer_relay_url,
4208 "slot_id": pending.peer_slot_id,
4209 "slot_token": pending.peer_slot_token,
4210 });
4211 config::write_relay_state(&relay_state)?;
4212
4213 crate::pair_invite::send_pair_drop_ack(
4215 &pending.peer_handle,
4216 &pending.peer_relay_url,
4217 &pending.peer_slot_id,
4218 &pending.peer_slot_token,
4219 )
4220 .with_context(|| {
4221 format!(
4222 "pair_drop_ack send to {} @ {} slot {} failed",
4223 pending.peer_handle, pending.peer_relay_url, pending.peer_slot_id
4224 )
4225 })?;
4226
4227 crate::pending_inbound_pair::consume_pending_inbound(peer_nick)?;
4229
4230 if as_json {
4231 println!(
4232 "{}",
4233 serde_json::to_string(&json!({
4234 "handle": handle_arg,
4235 "paired_with": pending.peer_did,
4236 "peer_handle": pending.peer_handle,
4237 "status": "bilateral_accepted",
4238 "via": "pending_inbound",
4239 }))?
4240 );
4241 } else {
4242 println!(
4243 "→ 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} \"...\"`.",
4244 peer = pending.peer_handle,
4245 );
4246 }
4247 Ok(())
4248}
4249
4250fn cmd_pair_accept(peer_nick: &str, as_json: bool) -> Result<()> {
4257 let nick = crate::agent_card::bare_handle(peer_nick);
4258 let pending = crate::pending_inbound_pair::read_pending_inbound(nick)?.ok_or_else(|| {
4259 anyhow!(
4260 "no pending pair request from {nick}. Run `wire pair-list-inbound` to see who is waiting, \
4261 or use `wire add <peer>@<relay>` to send a fresh outbound pair request."
4262 )
4263 })?;
4264 let (_our_did, our_relay, our_slot_id, our_slot_token) =
4265 crate::pair_invite::ensure_self_with_relay(None)?;
4266 let handle_arg = format!("{}@{}", pending.peer_handle, pending.peer_relay_url);
4267 cmd_add_accept_pending(
4268 &handle_arg,
4269 nick,
4270 &pending,
4271 &our_relay,
4272 &our_slot_id,
4273 &our_slot_token,
4274 as_json,
4275 )
4276}
4277
4278fn cmd_pair_list_inbound(as_json: bool) -> Result<()> {
4281 let items = crate::pending_inbound_pair::list_pending_inbound()?;
4282 if as_json {
4283 println!("{}", serde_json::to_string(&items)?);
4284 return Ok(());
4285 }
4286 if items.is_empty() {
4287 println!("no pending inbound pair requests.");
4288 return Ok(());
4289 }
4290 println!("{:<20} {:<35} {:<25} DID", "PEER", "RELAY", "RECEIVED");
4291 for p in items {
4292 println!(
4293 "{:<20} {:<35} {:<25} {}",
4294 p.peer_handle, p.peer_relay_url, p.received_at, p.peer_did,
4295 );
4296 }
4297 println!(
4298 "→ accept with `wire pair-accept <peer>`; refuse with `wire pair-reject <peer>`."
4299 );
4300 Ok(())
4301}
4302
4303fn cmd_pair_reject(peer_nick: &str, as_json: bool) -> Result<()> {
4307 let nick = crate::agent_card::bare_handle(peer_nick);
4308 let existed = crate::pending_inbound_pair::read_pending_inbound(nick)?;
4309 crate::pending_inbound_pair::consume_pending_inbound(nick)?;
4310
4311 if as_json {
4312 println!(
4313 "{}",
4314 serde_json::to_string(&json!({
4315 "peer": nick,
4316 "rejected": existed.is_some(),
4317 "had_pending": existed.is_some(),
4318 }))?
4319 );
4320 } else if existed.is_some() {
4321 println!("→ rejected pending pair from {nick}\n→ pending-inbound record deleted; no ack sent.");
4322 } else {
4323 println!("no pending pair from {nick} — nothing to reject");
4324 }
4325 Ok(())
4326}
4327
4328fn cmd_session(cmd: SessionCommand) -> Result<()> {
4337 match cmd {
4338 SessionCommand::New {
4339 name,
4340 relay,
4341 no_daemon,
4342 json,
4343 } => cmd_session_new(name.as_deref(), &relay, no_daemon, json),
4344 SessionCommand::List { json } => cmd_session_list(json),
4345 SessionCommand::Env { name, json } => cmd_session_env(name.as_deref(), json),
4346 SessionCommand::Current { json } => cmd_session_current(json),
4347 SessionCommand::Destroy { name, force, json } => cmd_session_destroy(&name, force, json),
4348 }
4349}
4350
4351fn resolve_session_name(name: Option<&str>) -> Result<String> {
4352 if let Some(n) = name {
4353 return Ok(crate::session::sanitize_name(n));
4354 }
4355 let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
4356 let registry = crate::session::read_registry().unwrap_or_default();
4357 Ok(crate::session::derive_name_from_cwd(&cwd, ®istry))
4358}
4359
4360fn cmd_session_new(
4361 name_arg: Option<&str>,
4362 relay: &str,
4363 no_daemon: bool,
4364 as_json: bool,
4365) -> Result<()> {
4366 let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
4367 let mut registry = crate::session::read_registry().unwrap_or_default();
4368 let name = match name_arg {
4369 Some(n) => crate::session::sanitize_name(n),
4370 None => crate::session::derive_name_from_cwd(&cwd, ®istry),
4371 };
4372 let session_home = crate::session::session_dir(&name)?;
4373
4374 let already_exists = session_home.exists()
4375 && session_home
4376 .join("config")
4377 .join("wire")
4378 .join("agent-card.json")
4379 .exists();
4380 if already_exists {
4381 registry
4385 .by_cwd
4386 .insert(cwd.to_string_lossy().into_owned(), name.clone());
4387 crate::session::write_registry(®istry)?;
4388 let info = render_session_info(&name, &session_home, &cwd)?;
4389 emit_session_new_result(&info, "already_exists", as_json)?;
4390 if !no_daemon {
4391 ensure_session_daemon(&session_home)?;
4392 }
4393 return Ok(());
4394 }
4395
4396 std::fs::create_dir_all(&session_home)
4397 .with_context(|| format!("creating session dir {session_home:?}"))?;
4398
4399 let init_status = run_wire_with_home(
4401 &session_home,
4402 &["init", &name, "--relay", relay],
4403 )?;
4404 if !init_status.success() {
4405 bail!(
4406 "`wire init {name} --relay {relay}` failed inside session dir {session_home:?}"
4407 );
4408 }
4409
4410 let mut claim_attempt = 0u32;
4415 let mut effective_handle = name.clone();
4416 loop {
4417 claim_attempt += 1;
4418 let status = run_wire_with_home(
4419 &session_home,
4420 &["claim", &effective_handle, "--relay", relay],
4421 )?;
4422 if status.success() {
4423 break;
4424 }
4425 if claim_attempt >= 5 {
4426 bail!(
4427 "5 failed attempts to claim a handle on {relay} for session {name}. \
4428 Try `wire session destroy {name} --force` and re-run with a different name."
4429 );
4430 }
4431 let attempt_path = cwd.join(format!("__attempt_{claim_attempt}"));
4435 let suffix = crate::session::derive_name_from_cwd(&attempt_path, ®istry);
4436 let token = suffix
4440 .rsplit('-')
4441 .next()
4442 .filter(|t| t.len() == 4)
4443 .map(str::to_string)
4444 .unwrap_or_else(|| format!("{claim_attempt}"));
4445 effective_handle = format!("{name}-{token}");
4446 }
4447
4448 registry
4451 .by_cwd
4452 .insert(cwd.to_string_lossy().into_owned(), name.clone());
4453 crate::session::write_registry(®istry)?;
4454
4455 if !no_daemon {
4456 ensure_session_daemon(&session_home)?;
4457 }
4458
4459 let info = render_session_info(&name, &session_home, &cwd)?;
4460 emit_session_new_result(&info, "created", as_json)
4461}
4462
4463fn render_session_info(
4464 name: &str,
4465 session_home: &std::path::Path,
4466 cwd: &std::path::Path,
4467) -> Result<serde_json::Value> {
4468 let card_path = session_home.join("config").join("wire").join("agent-card.json");
4469 let (did, handle) = if card_path.exists() {
4470 let card: Value = serde_json::from_slice(&std::fs::read(&card_path)?)?;
4471 let did = card
4472 .get("did")
4473 .and_then(Value::as_str)
4474 .unwrap_or("")
4475 .to_string();
4476 let handle = card
4477 .get("handle")
4478 .and_then(Value::as_str)
4479 .map(str::to_string)
4480 .unwrap_or_else(|| {
4481 crate::agent_card::display_handle_from_did(&did).to_string()
4482 });
4483 (did, handle)
4484 } else {
4485 (String::new(), String::new())
4486 };
4487 Ok(json!({
4488 "name": name,
4489 "home_dir": session_home.to_string_lossy(),
4490 "cwd": cwd.to_string_lossy(),
4491 "did": did,
4492 "handle": handle,
4493 "export": format!("export WIRE_HOME={}", session_home.to_string_lossy()),
4494 }))
4495}
4496
4497fn emit_session_new_result(
4498 info: &serde_json::Value,
4499 status: &str,
4500 as_json: bool,
4501) -> Result<()> {
4502 if as_json {
4503 let mut obj = info.clone();
4504 obj["status"] = json!(status);
4505 println!("{}", serde_json::to_string(&obj)?);
4506 } else {
4507 let name = info["name"].as_str().unwrap_or("?");
4508 let handle = info["handle"].as_str().unwrap_or("?");
4509 let home = info["home_dir"].as_str().unwrap_or("?");
4510 let did = info["did"].as_str().unwrap_or("?");
4511 let export = info["export"].as_str().unwrap_or("?");
4512 let prefix = if status == "already_exists" {
4513 "session already exists (re-registered cwd)"
4514 } else {
4515 "session created"
4516 };
4517 println!(
4518 "{prefix}\n name: {name}\n handle: {handle}\n did: {did}\n home: {home}\n\nactivate with:\n {export}"
4519 );
4520 }
4521 Ok(())
4522}
4523
4524fn run_wire_with_home(
4525 session_home: &std::path::Path,
4526 args: &[&str],
4527) -> Result<std::process::ExitStatus> {
4528 let bin = std::env::current_exe().with_context(|| "locating self exe")?;
4529 let status = std::process::Command::new(&bin)
4530 .env("WIRE_HOME", session_home)
4531 .env_remove("RUST_LOG")
4532 .args(args)
4533 .status()
4534 .with_context(|| format!("spawning `wire {}`", args.join(" ")))?;
4535 Ok(status)
4536}
4537
4538fn ensure_session_daemon(session_home: &std::path::Path) -> Result<()> {
4539 let pidfile = session_home
4542 .join("state")
4543 .join("wire")
4544 .join("daemon.pid");
4545 if pidfile.exists() {
4546 let bytes = std::fs::read(&pidfile).unwrap_or_default();
4547 let pid: Option<u32> =
4548 if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
4549 v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
4550 } else {
4551 String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
4552 };
4553 if let Some(p) = pid {
4554 let alive = {
4555 #[cfg(target_os = "linux")]
4556 {
4557 std::path::Path::new(&format!("/proc/{p}")).exists()
4558 }
4559 #[cfg(not(target_os = "linux"))]
4560 {
4561 std::process::Command::new("kill")
4562 .args(["-0", &p.to_string()])
4563 .output()
4564 .map(|o| o.status.success())
4565 .unwrap_or(false)
4566 }
4567 };
4568 if alive {
4569 return Ok(());
4570 }
4571 }
4572 }
4573
4574 let bin = std::env::current_exe().with_context(|| "locating self exe")?;
4577 let log_path = session_home.join("state").join("wire").join("daemon.log");
4578 if let Some(parent) = log_path.parent() {
4579 std::fs::create_dir_all(parent).ok();
4580 }
4581 let log_file = std::fs::OpenOptions::new()
4582 .create(true)
4583 .append(true)
4584 .open(&log_path)
4585 .with_context(|| format!("opening daemon log {log_path:?}"))?;
4586 let log_err = log_file.try_clone()?;
4587 std::process::Command::new(&bin)
4588 .env("WIRE_HOME", session_home)
4589 .env_remove("RUST_LOG")
4590 .args(["daemon", "--interval", "5"])
4591 .stdout(log_file)
4592 .stderr(log_err)
4593 .stdin(std::process::Stdio::null())
4594 .spawn()
4595 .with_context(|| "spawning session-local `wire daemon`")?;
4596 Ok(())
4597}
4598
4599fn cmd_session_list(as_json: bool) -> Result<()> {
4600 let items = crate::session::list_sessions()?;
4601 if as_json {
4602 println!("{}", serde_json::to_string(&items)?);
4603 return Ok(());
4604 }
4605 if items.is_empty() {
4606 println!("no sessions on this machine. `wire session new` to create one.");
4607 return Ok(());
4608 }
4609 println!(
4610 "{:<24} {:<24} {:<10} CWD",
4611 "NAME", "HANDLE", "DAEMON"
4612 );
4613 for s in items {
4614 println!(
4615 "{:<24} {:<24} {:<10} {}",
4616 s.name,
4617 s.handle.as_deref().unwrap_or("?"),
4618 if s.daemon_running { "running" } else { "down" },
4619 s.cwd.as_deref().unwrap_or("(no cwd registered)"),
4620 );
4621 }
4622 Ok(())
4623}
4624
4625fn cmd_session_env(name_arg: Option<&str>, as_json: bool) -> Result<()> {
4626 let name = resolve_session_name(name_arg)?;
4627 let session_home = crate::session::session_dir(&name)?;
4628 if !session_home.exists() {
4629 bail!(
4630 "no session named {name:?} on this machine. `wire session list` to enumerate, \
4631 `wire session new {name}` to create."
4632 );
4633 }
4634 if as_json {
4635 println!(
4636 "{}",
4637 serde_json::to_string(&json!({
4638 "name": name,
4639 "home_dir": session_home.to_string_lossy(),
4640 "export": format!("export WIRE_HOME={}", session_home.to_string_lossy()),
4641 }))?
4642 );
4643 } else {
4644 println!("export WIRE_HOME={}", session_home.to_string_lossy());
4645 }
4646 Ok(())
4647}
4648
4649fn cmd_session_current(as_json: bool) -> Result<()> {
4650 let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
4651 let registry = crate::session::read_registry().unwrap_or_default();
4652 let cwd_key = cwd.to_string_lossy().into_owned();
4653 let name = registry.by_cwd.get(&cwd_key).cloned();
4654 if as_json {
4655 println!(
4656 "{}",
4657 serde_json::to_string(&json!({
4658 "cwd": cwd_key,
4659 "session": name,
4660 }))?
4661 );
4662 } else if let Some(n) = name {
4663 println!("{n}");
4664 } else {
4665 println!("(no session registered for this cwd)");
4666 }
4667 Ok(())
4668}
4669
4670fn cmd_session_destroy(name_arg: &str, force: bool, as_json: bool) -> Result<()> {
4671 let name = crate::session::sanitize_name(name_arg);
4672 let session_home = crate::session::session_dir(&name)?;
4673 if !session_home.exists() {
4674 if as_json {
4675 println!(
4676 "{}",
4677 serde_json::to_string(&json!({
4678 "name": name,
4679 "destroyed": false,
4680 "reason": "no such session",
4681 }))?
4682 );
4683 } else {
4684 println!("no session named {name:?} — nothing to destroy.");
4685 }
4686 return Ok(());
4687 }
4688 if !force {
4689 bail!(
4690 "destroying session {name:?} would delete its keypair + state irrecoverably. \
4691 Pass --force to confirm."
4692 );
4693 }
4694
4695 let pidfile = session_home
4697 .join("state")
4698 .join("wire")
4699 .join("daemon.pid");
4700 if let Ok(bytes) = std::fs::read(&pidfile) {
4701 let pid: Option<u32> =
4702 if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
4703 v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
4704 } else {
4705 String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
4706 };
4707 if let Some(p) = pid {
4708 let _ = std::process::Command::new("kill")
4709 .args(["-TERM", &p.to_string()])
4710 .output();
4711 }
4712 }
4713
4714 std::fs::remove_dir_all(&session_home)
4715 .with_context(|| format!("removing session dir {session_home:?}"))?;
4716
4717 let mut registry = crate::session::read_registry().unwrap_or_default();
4719 registry.by_cwd.retain(|_, v| v != &name);
4720 crate::session::write_registry(®istry)?;
4721
4722 if as_json {
4723 println!(
4724 "{}",
4725 serde_json::to_string(&json!({
4726 "name": name,
4727 "destroyed": true,
4728 }))?
4729 );
4730 } else {
4731 println!("destroyed session {name:?}.");
4732 }
4733 Ok(())
4734}
4735
4736fn cmd_diag(action: DiagAction) -> Result<()> {
4739 let state = config::state_dir()?;
4740 let knob = state.join("diag.enabled");
4741 let log_path = state.join("diag.jsonl");
4742 match action {
4743 DiagAction::Tail { limit, json } => {
4744 let entries = crate::diag::tail(limit);
4745 if json {
4746 for e in entries {
4747 println!("{}", serde_json::to_string(&e)?);
4748 }
4749 } else if entries.is_empty() {
4750 println!("wire diag: no entries (diag may be disabled — `wire diag enable`)");
4751 } else {
4752 for e in entries {
4753 let ts = e["ts"].as_u64().unwrap_or(0);
4754 let ty = e["type"].as_str().unwrap_or("?");
4755 let pid = e["pid"].as_u64().unwrap_or(0);
4756 let payload = e["payload"].to_string();
4757 println!("[{ts}] pid={pid} {ty} {payload}");
4758 }
4759 }
4760 }
4761 DiagAction::Enable => {
4762 config::ensure_dirs()?;
4763 std::fs::write(&knob, "1")?;
4764 println!("wire diag: enabled at {knob:?}");
4765 }
4766 DiagAction::Disable => {
4767 if knob.exists() {
4768 std::fs::remove_file(&knob)?;
4769 }
4770 println!("wire diag: disabled (env WIRE_DIAG may still flip it on per-process)");
4771 }
4772 DiagAction::Status { json } => {
4773 let enabled = crate::diag::is_enabled();
4774 let size = std::fs::metadata(&log_path)
4775 .map(|m| m.len())
4776 .unwrap_or(0);
4777 if json {
4778 println!(
4779 "{}",
4780 serde_json::to_string(&serde_json::json!({
4781 "enabled": enabled,
4782 "log_path": log_path,
4783 "log_size_bytes": size,
4784 }))?
4785 );
4786 } else {
4787 println!("wire diag status");
4788 println!(" enabled: {enabled}");
4789 println!(" log: {log_path:?}");
4790 println!(" log size: {size} bytes");
4791 }
4792 }
4793 }
4794 Ok(())
4795}
4796
4797fn cmd_service(action: ServiceAction) -> Result<()> {
4800 let (report, as_json) = match action {
4801 ServiceAction::Install { json } => (crate::service::install()?, json),
4802 ServiceAction::Uninstall { json } => (crate::service::uninstall()?, json),
4803 ServiceAction::Status { json } => (crate::service::status()?, json),
4804 };
4805 if as_json {
4806 println!("{}", serde_json::to_string(&report)?);
4807 } else {
4808 println!("wire service {}", report.action);
4809 println!(" platform: {}", report.platform);
4810 println!(" unit: {}", report.unit_path);
4811 println!(" status: {}", report.status);
4812 println!(" detail: {}", report.detail);
4813 }
4814 Ok(())
4815}
4816
4817fn cmd_upgrade(check_only: bool, as_json: bool) -> Result<()> {
4832 let pgrep_out = std::process::Command::new("pgrep")
4834 .args(["-f", "wire daemon"])
4835 .output();
4836 let running_pids: Vec<u32> = match pgrep_out {
4837 Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
4838 .split_whitespace()
4839 .filter_map(|s| s.parse::<u32>().ok())
4840 .collect(),
4841 _ => Vec::new(),
4842 };
4843
4844 let record = crate::ensure_up::read_pid_record("daemon");
4846 let recorded_version: Option<String> = match &record {
4847 crate::ensure_up::PidRecord::Json(d) => Some(d.version.clone()),
4848 crate::ensure_up::PidRecord::LegacyInt(_) => Some("<pre-0.5.11>".to_string()),
4849 _ => None,
4850 };
4851 let cli_version = env!("CARGO_PKG_VERSION").to_string();
4852
4853 if check_only {
4854 let report = json!({
4855 "running_pids": running_pids,
4856 "pidfile_version": recorded_version,
4857 "cli_version": cli_version,
4858 "would_kill": running_pids,
4859 });
4860 if as_json {
4861 println!("{}", serde_json::to_string(&report)?);
4862 } else {
4863 println!("wire upgrade --check");
4864 println!(" cli version: {cli_version}");
4865 println!(" pidfile version: {}", recorded_version.as_deref().unwrap_or("(missing)"));
4866 if running_pids.is_empty() {
4867 println!(" running daemons: none");
4868 } else {
4869 let pids: Vec<String> = running_pids.iter().map(|p| p.to_string()).collect();
4870 println!(" running daemons: pids {}", pids.join(", "));
4871 println!(" would kill all + spawn fresh");
4872 }
4873 }
4874 return Ok(());
4875 }
4876
4877 let mut killed: Vec<u32> = Vec::new();
4880 for pid in &running_pids {
4881 let _ = std::process::Command::new("kill")
4883 .args(["-15", &pid.to_string()])
4884 .status();
4885 killed.push(*pid);
4886 }
4887 if !killed.is_empty() {
4889 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
4890 loop {
4891 let still_alive: Vec<u32> = killed
4892 .iter()
4893 .copied()
4894 .filter(|p| process_alive_pid(*p))
4895 .collect();
4896 if still_alive.is_empty() {
4897 break;
4898 }
4899 if std::time::Instant::now() >= deadline {
4900 for pid in still_alive {
4902 let _ = std::process::Command::new("kill")
4903 .args(["-9", &pid.to_string()])
4904 .status();
4905 }
4906 break;
4907 }
4908 std::thread::sleep(std::time::Duration::from_millis(50));
4909 }
4910 }
4911
4912 let pidfile = config::state_dir()?.join("daemon.pid");
4915 if pidfile.exists() {
4916 let _ = std::fs::remove_file(&pidfile);
4917 }
4918
4919 let spawned = crate::ensure_up::ensure_daemon_running()?;
4922
4923 let new_record = crate::ensure_up::read_pid_record("daemon");
4924 let new_pid = new_record.pid();
4925 let new_version: Option<String> = if let crate::ensure_up::PidRecord::Json(d) = &new_record {
4926 Some(d.version.clone())
4927 } else {
4928 None
4929 };
4930
4931 if as_json {
4932 println!(
4933 "{}",
4934 serde_json::to_string(&json!({
4935 "killed": killed,
4936 "spawned_fresh_daemon": spawned,
4937 "new_pid": new_pid,
4938 "new_version": new_version,
4939 "cli_version": cli_version,
4940 }))?
4941 );
4942 } else {
4943 if killed.is_empty() {
4944 println!("wire upgrade: no stale daemons running");
4945 } else {
4946 println!("wire upgrade: killed {} daemon(s) (pids {})",
4947 killed.len(),
4948 killed.iter().map(|p| p.to_string()).collect::<Vec<_>>().join(", "));
4949 }
4950 if spawned {
4951 println!(
4952 "wire upgrade: spawned fresh daemon (pid {} v{})",
4953 new_pid.map(|p| p.to_string()).unwrap_or_else(|| "?".to_string()),
4954 new_version.as_deref().unwrap_or(&cli_version),
4955 );
4956 } else {
4957 println!("wire upgrade: daemon was already running on current binary");
4958 }
4959 }
4960 Ok(())
4961}
4962
4963fn process_alive_pid(pid: u32) -> bool {
4964 #[cfg(target_os = "linux")]
4965 {
4966 std::path::Path::new(&format!("/proc/{pid}")).exists()
4967 }
4968 #[cfg(not(target_os = "linux"))]
4969 {
4970 std::process::Command::new("kill")
4971 .args(["-0", &pid.to_string()])
4972 .stdin(std::process::Stdio::null())
4973 .stdout(std::process::Stdio::null())
4974 .stderr(std::process::Stdio::null())
4975 .status()
4976 .map(|s| s.success())
4977 .unwrap_or(false)
4978 }
4979}
4980
4981#[derive(Clone, Debug, serde::Serialize)]
4985pub struct DoctorCheck {
4986 pub id: String,
4989 pub status: String,
4991 pub detail: String,
4993 #[serde(skip_serializing_if = "Option::is_none")]
4995 pub fix: Option<String>,
4996}
4997
4998impl DoctorCheck {
4999 fn pass(id: &str, detail: impl Into<String>) -> Self {
5000 Self {
5001 id: id.into(),
5002 status: "PASS".into(),
5003 detail: detail.into(),
5004 fix: None,
5005 }
5006 }
5007 fn warn(id: &str, detail: impl Into<String>, fix: impl Into<String>) -> Self {
5008 Self {
5009 id: id.into(),
5010 status: "WARN".into(),
5011 detail: detail.into(),
5012 fix: Some(fix.into()),
5013 }
5014 }
5015 fn fail(id: &str, detail: impl Into<String>, fix: impl Into<String>) -> Self {
5016 Self {
5017 id: id.into(),
5018 status: "FAIL".into(),
5019 detail: detail.into(),
5020 fix: Some(fix.into()),
5021 }
5022 }
5023}
5024
5025fn cmd_doctor(as_json: bool, recent_rejections: usize) -> Result<()> {
5030 let mut checks: Vec<DoctorCheck> = Vec::new();
5031
5032 checks.push(check_daemon_health());
5033 checks.push(check_daemon_pid_consistency());
5034 checks.push(check_relay_reachable());
5035 checks.push(check_pair_rejections(recent_rejections));
5036 checks.push(check_cursor_progress());
5037
5038 let fails = checks.iter().filter(|c| c.status == "FAIL").count();
5039 let warns = checks.iter().filter(|c| c.status == "WARN").count();
5040
5041 if as_json {
5042 println!(
5043 "{}",
5044 serde_json::to_string(&json!({
5045 "checks": checks,
5046 "fail_count": fails,
5047 "warn_count": warns,
5048 "ok": fails == 0,
5049 }))?
5050 );
5051 } else {
5052 println!("wire doctor — {} checks", checks.len());
5053 for c in &checks {
5054 let bullet = match c.status.as_str() {
5055 "PASS" => "✓",
5056 "WARN" => "!",
5057 "FAIL" => "✗",
5058 _ => "?",
5059 };
5060 println!(" {bullet} [{}] {}: {}", c.status, c.id, c.detail);
5061 if let Some(fix) = &c.fix {
5062 println!(" fix: {fix}");
5063 }
5064 }
5065 println!();
5066 if fails == 0 && warns == 0 {
5067 println!("ALL GREEN");
5068 } else {
5069 println!("{fails} FAIL, {warns} WARN");
5070 }
5071 }
5072
5073 if fails > 0 {
5074 std::process::exit(1);
5075 }
5076 Ok(())
5077}
5078
5079fn check_daemon_health() -> DoctorCheck {
5086 let output = std::process::Command::new("pgrep")
5091 .args(["-f", "wire daemon"])
5092 .output();
5093 let pgrep_pids: Vec<u32> = match output {
5094 Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
5095 .split_whitespace()
5096 .filter_map(|s| s.parse::<u32>().ok())
5097 .collect(),
5098 _ => Vec::new(),
5099 };
5100 let pidfile_pid = crate::ensure_up::read_pid_record("daemon").pid();
5101 let pidfile_alive = pidfile_pid
5103 .map(|pid| {
5104 #[cfg(target_os = "linux")]
5105 {
5106 std::path::Path::new(&format!("/proc/{pid}")).exists()
5107 }
5108 #[cfg(not(target_os = "linux"))]
5109 {
5110 std::process::Command::new("kill")
5111 .args(["-0", &pid.to_string()])
5112 .output()
5113 .map(|o| o.status.success())
5114 .unwrap_or(false)
5115 }
5116 })
5117 .unwrap_or(false);
5118 let orphan_pids: Vec<u32> = pgrep_pids
5119 .iter()
5120 .filter(|p| Some(**p) != pidfile_pid)
5121 .copied()
5122 .collect();
5123
5124 let fmt_pids = |xs: &[u32]| -> String {
5125 xs.iter()
5126 .map(|p| p.to_string())
5127 .collect::<Vec<_>>()
5128 .join(", ")
5129 };
5130
5131 match (pgrep_pids.len(), pidfile_alive, orphan_pids.is_empty()) {
5132 (0, _, _) => DoctorCheck::fail(
5133 "daemon",
5134 "no `wire daemon` process running — nothing pulling inbox or pushing outbox",
5135 "`wire daemon &` to start, or re-run `wire up <handle>@<relay>` to bootstrap",
5136 ),
5137 (1, true, true) => DoctorCheck::pass(
5139 "daemon",
5140 format!(
5141 "one daemon running (pid {}, matches pidfile)",
5142 pgrep_pids[0]
5143 ),
5144 ),
5145 (n, true, false) => DoctorCheck::fail(
5147 "daemon",
5148 format!(
5149 "{n} `wire daemon` processes running (pids: {}); pidfile claims pid {} but pgrep also sees orphan(s): {}. \
5150 The orphans race the relay cursor — they advance past events your current binary can't process. \
5151 (Issue #2 exact class.)",
5152 fmt_pids(&pgrep_pids),
5153 pidfile_pid.unwrap(),
5154 fmt_pids(&orphan_pids),
5155 ),
5156 "`wire upgrade` kills all orphans and spawns a fresh daemon with a clean pidfile",
5157 ),
5158 (n, false, _) => DoctorCheck::fail(
5160 "daemon",
5161 format!(
5162 "{n} `wire daemon` process(es) running (pids: {}) but pidfile {} — \
5163 every running daemon is an orphan, advancing the cursor without coordinating with the current CLI. \
5164 (Issue #2 exact class: doctor previously PASSed this state while `wire status` said DOWN.)",
5165 fmt_pids(&pgrep_pids),
5166 match pidfile_pid {
5167 Some(p) => format!("claims pid {p} which is dead"),
5168 None => "is missing".to_string(),
5169 },
5170 ),
5171 "`wire upgrade` to kill the orphan(s) and spawn a fresh daemon",
5172 ),
5173 (n, true, true) => DoctorCheck::warn(
5175 "daemon",
5176 format!(
5177 "{n} `wire daemon` processes running (pids: {}). Multiple daemons race the relay cursor.",
5178 fmt_pids(&pgrep_pids)
5179 ),
5180 "kill all-but-one: `pkill -f \"wire daemon\"; wire daemon &`",
5181 ),
5182 }
5183}
5184
5185fn check_daemon_pid_consistency() -> DoctorCheck {
5191 let record = crate::ensure_up::read_pid_record("daemon");
5192 match record {
5193 crate::ensure_up::PidRecord::Missing => DoctorCheck::pass(
5194 "daemon_pid_consistency",
5195 "no daemon.pid yet — fresh box or daemon never started",
5196 ),
5197 crate::ensure_up::PidRecord::Corrupt(reason) => DoctorCheck::warn(
5198 "daemon_pid_consistency",
5199 format!("daemon.pid is corrupt: {reason}"),
5200 "delete state/wire/daemon.pid; next `wire daemon &` will rewrite",
5201 ),
5202 crate::ensure_up::PidRecord::LegacyInt(pid) => DoctorCheck::warn(
5203 "daemon_pid_consistency",
5204 format!(
5205 "daemon.pid is legacy-int form (pid={pid}, no version/bin_path metadata). \
5206 Daemon was started by a pre-0.5.11 binary."
5207 ),
5208 "run `wire upgrade` to kill the old daemon and start a fresh one with the JSON pidfile",
5209 ),
5210 crate::ensure_up::PidRecord::Json(d) => {
5211 let mut issues: Vec<String> = Vec::new();
5212 if d.schema != crate::ensure_up::DAEMON_PID_SCHEMA {
5213 issues.push(format!(
5214 "schema={} (expected {})",
5215 d.schema,
5216 crate::ensure_up::DAEMON_PID_SCHEMA
5217 ));
5218 }
5219 let cli_version = env!("CARGO_PKG_VERSION");
5220 if d.version != cli_version {
5221 issues.push(format!(
5222 "version daemon={} cli={cli_version}",
5223 d.version
5224 ));
5225 }
5226 if !std::path::Path::new(&d.bin_path).exists() {
5227 issues.push(format!("bin_path {} missing on disk", d.bin_path));
5228 }
5229 if let Ok(card) = config::read_agent_card()
5231 && let Some(current_did) = card.get("did").and_then(Value::as_str)
5232 && let Some(recorded_did) = &d.did
5233 && recorded_did != current_did
5234 {
5235 issues.push(format!(
5236 "did daemon={recorded_did} config={current_did} — identity drift"
5237 ));
5238 }
5239 if let Ok(state) = config::read_relay_state()
5240 && let Some(current_relay) = state
5241 .get("self")
5242 .and_then(|s| s.get("relay_url"))
5243 .and_then(Value::as_str)
5244 && let Some(recorded_relay) = &d.relay_url
5245 && recorded_relay != current_relay
5246 {
5247 issues.push(format!(
5248 "relay_url daemon={recorded_relay} config={current_relay} — relay-migration drift"
5249 ));
5250 }
5251 if issues.is_empty() {
5252 DoctorCheck::pass(
5253 "daemon_pid_consistency",
5254 format!(
5255 "daemon v{} bound to {} as {}",
5256 d.version,
5257 d.relay_url.as_deref().unwrap_or("?"),
5258 d.did.as_deref().unwrap_or("?")
5259 ),
5260 )
5261 } else {
5262 DoctorCheck::warn(
5263 "daemon_pid_consistency",
5264 format!("daemon pidfile drift: {}", issues.join("; ")),
5265 "`wire upgrade` to atomically restart daemon with current config".to_string(),
5266 )
5267 }
5268 }
5269 }
5270}
5271
5272fn check_relay_reachable() -> DoctorCheck {
5274 let state = match config::read_relay_state() {
5275 Ok(s) => s,
5276 Err(e) => return DoctorCheck::fail(
5277 "relay",
5278 format!("could not read relay state: {e}"),
5279 "run `wire up <handle>@<relay>` to bootstrap",
5280 ),
5281 };
5282 let url = state
5283 .get("self")
5284 .and_then(|s| s.get("relay_url"))
5285 .and_then(Value::as_str)
5286 .unwrap_or("");
5287 if url.is_empty() {
5288 return DoctorCheck::warn(
5289 "relay",
5290 "no relay bound — wire send/pull will not work",
5291 "run `wire bind-relay <url>` or `wire up <handle>@<relay>`",
5292 );
5293 }
5294 let client = crate::relay_client::RelayClient::new(url);
5295 match client.check_healthz() {
5296 Ok(()) => DoctorCheck::pass("relay", format!("{url} healthz=200")),
5297 Err(e) => DoctorCheck::fail(
5298 "relay",
5299 format!("{url} unreachable: {e}"),
5300 format!("network reachable to {url}? relay running? check `curl {url}/healthz`"),
5301 ),
5302 }
5303}
5304
5305fn check_pair_rejections(recent_n: usize) -> DoctorCheck {
5309 let path = match config::state_dir() {
5310 Ok(d) => d.join("pair-rejected.jsonl"),
5311 Err(e) => return DoctorCheck::warn(
5312 "pair_rejections",
5313 format!("could not resolve state dir: {e}"),
5314 "set WIRE_HOME or fix XDG_STATE_HOME",
5315 ),
5316 };
5317 if !path.exists() {
5318 return DoctorCheck::pass(
5319 "pair_rejections",
5320 "no pair-rejected.jsonl — no recorded pair failures",
5321 );
5322 }
5323 let body = match std::fs::read_to_string(&path) {
5324 Ok(b) => b,
5325 Err(e) => return DoctorCheck::warn(
5326 "pair_rejections",
5327 format!("could not read {path:?}: {e}"),
5328 "check file permissions",
5329 ),
5330 };
5331 let lines: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
5332 if lines.is_empty() {
5333 return DoctorCheck::pass(
5334 "pair_rejections",
5335 "pair-rejected.jsonl present but empty",
5336 );
5337 }
5338 let total = lines.len();
5339 let recent: Vec<&str> = lines.iter().rev().take(recent_n).rev().copied().collect();
5340 let mut summary: Vec<String> = Vec::new();
5341 for line in &recent {
5342 if let Ok(rec) = serde_json::from_str::<Value>(line) {
5343 let peer = rec.get("peer").and_then(Value::as_str).unwrap_or("?");
5344 let code = rec.get("code").and_then(Value::as_str).unwrap_or("?");
5345 summary.push(format!("{peer}/{code}"));
5346 }
5347 }
5348 DoctorCheck::warn(
5349 "pair_rejections",
5350 format!(
5351 "{total} pair failures recorded. recent: [{}]",
5352 summary.join(", ")
5353 ),
5354 format!(
5355 "inspect {path:?} for full details. Each entry is a pair-flow error that previously silently dropped — re-run `wire pair <handle>@<relay>` to retry."
5356 ),
5357 )
5358}
5359
5360fn check_cursor_progress() -> DoctorCheck {
5365 let state = match config::read_relay_state() {
5366 Ok(s) => s,
5367 Err(e) => return DoctorCheck::warn(
5368 "cursor",
5369 format!("could not read relay state: {e}"),
5370 "check ~/Library/Application Support/wire/relay.json",
5371 ),
5372 };
5373 let cursor = state
5374 .get("self")
5375 .and_then(|s| s.get("last_pulled_event_id"))
5376 .and_then(Value::as_str)
5377 .map(|s| s.chars().take(16).collect::<String>())
5378 .unwrap_or_else(|| "<none>".to_string());
5379 DoctorCheck::pass(
5380 "cursor",
5381 format!(
5382 "current cursor: {cursor}. P0.1 cursor blocking is active — see `wire pull --json` for cursor_blocked / rejected[].blocks_cursor entries."
5383 ),
5384 )
5385}
5386
5387#[cfg(test)]
5388mod doctor_tests {
5389 use super::*;
5390
5391 #[test]
5392 fn doctor_check_constructors_set_status_correctly() {
5393 let p = DoctorCheck::pass("x", "ok");
5398 assert_eq!(p.status, "PASS");
5399 assert_eq!(p.fix, None);
5400
5401 let w = DoctorCheck::warn("x", "watch out", "do this");
5402 assert_eq!(w.status, "WARN");
5403 assert_eq!(w.fix, Some("do this".to_string()));
5404
5405 let f = DoctorCheck::fail("x", "broken", "fix it");
5406 assert_eq!(f.status, "FAIL");
5407 assert_eq!(f.fix, Some("fix it".to_string()));
5408 }
5409
5410 #[test]
5411 fn check_pair_rejections_no_file_is_pass() {
5412 config::test_support::with_temp_home(|| {
5415 config::ensure_dirs().unwrap();
5416 let c = check_pair_rejections(5);
5417 assert_eq!(c.status, "PASS", "no file should be PASS, got {c:?}");
5418 });
5419 }
5420
5421 #[test]
5422 fn check_pair_rejections_with_entries_warns() {
5423 config::test_support::with_temp_home(|| {
5427 config::ensure_dirs().unwrap();
5428 crate::pair_invite::record_pair_rejection(
5429 "willard",
5430 "pair_drop_ack_send_failed",
5431 "POST 502",
5432 );
5433 let c = check_pair_rejections(5);
5434 assert_eq!(c.status, "WARN");
5435 assert!(c.detail.contains("1 pair failures"));
5436 assert!(c.detail.contains("willard/pair_drop_ack_send_failed"));
5437 });
5438 }
5439}
5440
5441fn cmd_up(handle_arg: &str, name: Option<&str>, as_json: bool) -> Result<()> {
5453 let (nick, relay_url) = match handle_arg.split_once('@') {
5454 Some((n, host)) => {
5455 let url = if host.starts_with("http://") || host.starts_with("https://") {
5456 host.to_string()
5457 } else {
5458 format!("https://{host}")
5459 };
5460 (n.to_string(), url)
5461 }
5462 None => (handle_arg.to_string(), crate::pair_invite::DEFAULT_RELAY.to_string()),
5463 };
5464
5465 let mut report: Vec<(String, String)> = Vec::new();
5466 let mut step = |stage: &str, detail: String| {
5467 report.push((stage.to_string(), detail.clone()));
5468 if !as_json {
5469 eprintln!("wire up: {stage} — {detail}");
5470 }
5471 };
5472
5473 if config::is_initialized()? {
5475 let card = config::read_agent_card()?;
5476 let existing_did = card.get("did").and_then(Value::as_str).unwrap_or("");
5477 let existing_handle =
5478 crate::agent_card::display_handle_from_did(existing_did).to_string();
5479 if existing_handle != nick {
5480 bail!(
5481 "wire up: already initialized as {existing_handle:?} but you asked for {nick:?}. \
5482 Either run with the existing handle (`wire up {existing_handle}@<relay>`) or \
5483 delete `{:?}` to start fresh.",
5484 config::config_dir()?
5485 );
5486 }
5487 step("init", format!("already initialized as {existing_handle}"));
5488 } else {
5489 cmd_init(&nick, name, Some(&relay_url), false)?;
5490 step("init", format!("created identity {nick} bound to {relay_url}"));
5491 }
5492
5493 let relay_state = config::read_relay_state()?;
5497 let bound_relay = relay_state
5498 .get("self")
5499 .and_then(|s| s.get("relay_url"))
5500 .and_then(Value::as_str)
5501 .unwrap_or("")
5502 .to_string();
5503 if bound_relay.is_empty() {
5504 cmd_bind_relay(&relay_url, false)?;
5506 step("bind-relay", format!("bound to {relay_url}"));
5507 } else if bound_relay != relay_url {
5508 step(
5509 "bind-relay",
5510 format!(
5511 "WARNING: identity bound to {bound_relay} but you specified {relay_url}. \
5512 Keeping existing binding. Run `wire bind-relay {relay_url}` to switch."
5513 ),
5514 );
5515 } else {
5516 step("bind-relay", format!("already bound to {bound_relay}"));
5517 }
5518
5519 match cmd_claim(&nick, Some(&relay_url), None, false) {
5522 Ok(()) => step("claim", format!("{nick}@{} claimed", strip_proto(&relay_url))),
5523 Err(e) => step(
5524 "claim",
5525 format!("WARNING: claim failed: {e}. You can retry `wire claim {nick}`."),
5526 ),
5527 }
5528
5529 match crate::ensure_up::ensure_daemon_running() {
5531 Ok(true) => step("daemon", "started fresh background daemon".to_string()),
5532 Ok(false) => step("daemon", "already running".to_string()),
5533 Err(e) => step(
5534 "daemon",
5535 format!("WARNING: could not start daemon: {e}. Run `wire daemon &` manually."),
5536 ),
5537 }
5538
5539 let summary = format!(
5541 "ready. `wire pair <peer>@<relay>` to pair, `wire send <peer> \"<msg>\"` to send, \
5542 `wire monitor` to watch incoming events."
5543 );
5544 step("ready", summary.clone());
5545
5546 if as_json {
5547 let steps_json: Vec<_> = report
5548 .iter()
5549 .map(|(k, v)| json!({"stage": k, "detail": v}))
5550 .collect();
5551 println!(
5552 "{}",
5553 serde_json::to_string(&json!({
5554 "nick": nick,
5555 "relay": relay_url,
5556 "steps": steps_json,
5557 }))?
5558 );
5559 }
5560 Ok(())
5561}
5562
5563fn strip_proto(url: &str) -> String {
5565 url.trim_start_matches("https://")
5566 .trim_start_matches("http://")
5567 .to_string()
5568}
5569
5570fn cmd_pair_megacommand(
5584 handle_arg: &str,
5585 relay_override: Option<&str>,
5586 timeout_secs: u64,
5587 _as_json: bool,
5588) -> Result<()> {
5589 let parsed = crate::pair_profile::parse_handle(handle_arg)?;
5590 let peer_handle = parsed.nick.clone();
5591
5592 eprintln!("wire pair: resolving {handle_arg}...");
5593 cmd_add(handle_arg, relay_override, false)?;
5594
5595 eprintln!(
5596 "wire pair: intro delivered. waiting up to {timeout_secs}s for {peer_handle} \
5597 to ack (their daemon must be running + pulling)..."
5598 );
5599
5600 let _ = run_sync_pull();
5604
5605 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
5606 let poll_interval = std::time::Duration::from_millis(500);
5607
5608 loop {
5609 let _ = run_sync_pull();
5611 let relay_state = config::read_relay_state()?;
5612 let peer_entry = relay_state
5613 .get("peers")
5614 .and_then(|p| p.get(&peer_handle))
5615 .cloned();
5616 let token = peer_entry
5617 .as_ref()
5618 .and_then(|e| e.get("slot_token"))
5619 .and_then(Value::as_str)
5620 .unwrap_or("");
5621
5622 if !token.is_empty() {
5623 let trust = config::read_trust()?;
5625 let pinned_in_trust = trust
5626 .get("agents")
5627 .and_then(|a| a.get(&peer_handle))
5628 .is_some();
5629 println!(
5630 "wire pair: paired with {peer_handle}.\n trust: {} bilateral: yes (slot_token recorded)\n next: `wire send {peer_handle} \"<msg>\"`",
5631 if pinned_in_trust { "VERIFIED" } else { "MISSING (bug)" }
5632 );
5633 return Ok(());
5634 }
5635
5636 if std::time::Instant::now() >= deadline {
5637 bail!(
5644 "wire pair: timed out after {timeout_secs}s. \
5645 peer {peer_handle} never sent pair_drop_ack. \
5646 likely causes: (a) their daemon is down — ask them to run \
5647 `wire status` and `wire daemon &`; (b) their binary is older \
5648 than 0.5.x and doesn't understand pair_drop events — ask \
5649 them to `wire upgrade`; (c) network / relay blip — re-run \
5650 `wire pair {handle_arg}` to retry."
5651 );
5652 }
5653
5654 std::thread::sleep(poll_interval);
5655 }
5656}
5657
5658fn cmd_claim(
5659 nick: &str,
5660 relay_override: Option<&str>,
5661 public_url: Option<&str>,
5662 as_json: bool,
5663) -> Result<()> {
5664 if !crate::pair_profile::is_valid_nick(nick) {
5665 bail!(
5666 "phyllis: {nick:?} won't fit in the books — handles need 2-32 chars, lowercase [a-z0-9_-], not on the reserved list"
5667 );
5668 }
5669 let (_did, relay_url, slot_id, slot_token) =
5672 crate::pair_invite::ensure_self_with_relay(relay_override)?;
5673 let card = config::read_agent_card()?;
5674
5675 let client = crate::relay_client::RelayClient::new(&relay_url);
5676 let resp = client.handle_claim(nick, &slot_id, &slot_token, public_url, &card)?;
5677
5678 if as_json {
5679 println!(
5680 "{}",
5681 serde_json::to_string(&json!({
5682 "nick": nick,
5683 "relay": relay_url,
5684 "response": resp,
5685 }))?
5686 );
5687 } else {
5688 let domain = public_url
5692 .unwrap_or(&relay_url)
5693 .trim_start_matches("https://")
5694 .trim_start_matches("http://")
5695 .trim_end_matches('/')
5696 .split('/')
5697 .next()
5698 .unwrap_or("<this-relay-domain>")
5699 .to_string();
5700 println!("claimed {nick} on {relay_url} — others can reach you at: {nick}@{domain}");
5701 println!("verify with: wire whois {nick}@{domain}");
5702 }
5703 Ok(())
5704}
5705
5706fn cmd_profile(action: ProfileAction) -> Result<()> {
5707 match action {
5708 ProfileAction::Set { field, value, json } => {
5709 let parsed: Value =
5713 serde_json::from_str(&value).unwrap_or(Value::String(value.clone()));
5714 let new_profile = crate::pair_profile::write_profile_field(&field, parsed)?;
5715 if json {
5716 println!(
5717 "{}",
5718 serde_json::to_string(&json!({
5719 "field": field,
5720 "profile": new_profile,
5721 }))?
5722 );
5723 } else {
5724 println!("profile.{field} set");
5725 }
5726 }
5727 ProfileAction::Get { json } => return cmd_whois(None, json, None),
5728 ProfileAction::Clear { field, json } => {
5729 let new_profile = crate::pair_profile::write_profile_field(&field, Value::Null)?;
5730 if json {
5731 println!(
5732 "{}",
5733 serde_json::to_string(&json!({
5734 "field": field,
5735 "cleared": true,
5736 "profile": new_profile,
5737 }))?
5738 );
5739 } else {
5740 println!("profile.{field} cleared");
5741 }
5742 }
5743 }
5744 Ok(())
5745}
5746
5747fn cmd_setup(apply: bool) -> Result<()> {
5750 use std::path::PathBuf;
5751
5752 let entry = json!({"command": "wire", "args": ["mcp"]});
5753 let entry_pretty = serde_json::to_string_pretty(&json!({"wire": &entry}))?;
5754
5755 let mut targets: Vec<(&str, PathBuf)> = Vec::new();
5758 if let Some(home) = dirs::home_dir() {
5759 targets.push(("Claude Code", home.join(".claude.json")));
5762 targets.push(("Claude Code (alt)", home.join(".config/claude/mcp.json")));
5764 #[cfg(target_os = "macos")]
5766 targets.push((
5767 "Claude Desktop (macOS)",
5768 home.join("Library/Application Support/Claude/claude_desktop_config.json"),
5769 ));
5770 #[cfg(target_os = "windows")]
5772 if let Ok(appdata) = std::env::var("APPDATA") {
5773 targets.push((
5774 "Claude Desktop (Windows)",
5775 PathBuf::from(appdata).join("Claude/claude_desktop_config.json"),
5776 ));
5777 }
5778 targets.push(("Cursor", home.join(".cursor/mcp.json")));
5780 }
5781 targets.push(("project-local (.mcp.json)", PathBuf::from(".mcp.json")));
5783
5784 println!("wire setup\n");
5785 println!("MCP server snippet (add this to your client's mcpServers):");
5786 println!();
5787 println!("{entry_pretty}");
5788 println!();
5789
5790 if !apply {
5791 println!("Probable MCP host config locations on this machine:");
5792 for (name, path) in &targets {
5793 let marker = if path.exists() {
5794 "✓ found"
5795 } else {
5796 " (would create)"
5797 };
5798 println!(" {marker:14} {name}: {}", path.display());
5799 }
5800 println!();
5801 println!("Run `wire setup --apply` to merge wire into each config above.");
5802 println!(
5803 "Existing entries with a different command keep yours unchanged unless wire's exact entry is missing."
5804 );
5805 return Ok(());
5806 }
5807
5808 let mut modified: Vec<String> = Vec::new();
5809 let mut skipped: Vec<String> = Vec::new();
5810 for (name, path) in &targets {
5811 match upsert_mcp_entry(path, "wire", &entry) {
5812 Ok(true) => modified.push(format!("✓ {name} ({})", path.display())),
5813 Ok(false) => skipped.push(format!(" {name} ({}): already configured", path.display())),
5814 Err(e) => skipped.push(format!("✗ {name} ({}): {e}", path.display())),
5815 }
5816 }
5817 if !modified.is_empty() {
5818 println!("Modified:");
5819 for line in &modified {
5820 println!(" {line}");
5821 }
5822 println!();
5823 println!("Restart the app(s) above to load wire MCP.");
5824 }
5825 if !skipped.is_empty() {
5826 println!();
5827 println!("Skipped:");
5828 for line in &skipped {
5829 println!(" {line}");
5830 }
5831 }
5832 Ok(())
5833}
5834
5835fn upsert_mcp_entry(path: &std::path::Path, server_name: &str, entry: &Value) -> Result<bool> {
5838 let mut cfg: Value = if path.exists() {
5839 let body = std::fs::read_to_string(path).context("reading config")?;
5840 serde_json::from_str(&body).unwrap_or_else(|_| json!({}))
5841 } else {
5842 json!({})
5843 };
5844 if !cfg.is_object() {
5845 cfg = json!({});
5846 }
5847 let root = cfg.as_object_mut().unwrap();
5848 let servers = root
5849 .entry("mcpServers".to_string())
5850 .or_insert_with(|| json!({}));
5851 if !servers.is_object() {
5852 *servers = json!({});
5853 }
5854 let map = servers.as_object_mut().unwrap();
5855 if map.get(server_name) == Some(entry) {
5856 return Ok(false);
5857 }
5858 map.insert(server_name.to_string(), entry.clone());
5859 if let Some(parent) = path.parent()
5860 && !parent.as_os_str().is_empty()
5861 {
5862 std::fs::create_dir_all(parent).context("creating parent dir")?;
5863 }
5864 let out = serde_json::to_string_pretty(&cfg)? + "\n";
5865 std::fs::write(path, out).context("writing config")?;
5866 Ok(true)
5867}
5868
5869#[allow(clippy::too_many_arguments)]
5872fn cmd_reactor(
5873 on_event: &str,
5874 peer_filter: Option<&str>,
5875 kind_filter: Option<&str>,
5876 verified_only: bool,
5877 interval_secs: u64,
5878 once: bool,
5879 dry_run: bool,
5880 max_per_minute: u32,
5881 max_chain_depth: u32,
5882) -> Result<()> {
5883 use crate::inbox_watch::{InboxEvent, InboxWatcher};
5884 use std::collections::{HashMap, HashSet, VecDeque};
5885 use std::io::Write;
5886 use std::process::{Command, Stdio};
5887 use std::time::{Duration, Instant};
5888
5889 let cursor_path = config::state_dir()?.join("reactor.cursor");
5890 let emitted_path = config::state_dir()?.join("reactor-emitted.log");
5899 let mut emitted_ids: HashSet<String> = HashSet::new();
5900 if emitted_path.exists()
5901 && let Ok(body) = std::fs::read_to_string(&emitted_path)
5902 {
5903 for line in body.lines() {
5904 let t = line.trim();
5905 if !t.is_empty() {
5906 emitted_ids.insert(t.to_string());
5907 }
5908 }
5909 }
5910 let outbox_dir = config::outbox_dir()?;
5912 let mut outbox_cursors: HashMap<String, u64> = HashMap::new();
5915
5916 let mut watcher = InboxWatcher::from_cursor_file(&cursor_path)?;
5917
5918 let kind_num: Option<u32> = match kind_filter {
5919 Some(k) => Some(parse_kind(k)?),
5920 None => None,
5921 };
5922
5923 let mut peer_dispatch_log: HashMap<String, VecDeque<Instant>> = HashMap::new();
5925
5926 let dispatch = |ev: &InboxEvent,
5927 peer_dispatch_log: &mut HashMap<String, VecDeque<Instant>>,
5928 emitted_ids: &HashSet<String>|
5929 -> Result<bool> {
5930 if let Some(p) = peer_filter
5931 && ev.peer != p
5932 {
5933 return Ok(false);
5934 }
5935 if verified_only && !ev.verified {
5936 return Ok(false);
5937 }
5938 if let Some(want) = kind_num {
5939 let ev_kind = ev.raw.get("kind").and_then(Value::as_u64).map(|n| n as u32);
5940 if ev_kind != Some(want) {
5941 return Ok(false);
5942 }
5943 }
5944
5945 if max_chain_depth > 0 {
5949 let body_str = match &ev.raw["body"] {
5950 Value::String(s) => s.clone(),
5951 other => serde_json::to_string(other).unwrap_or_default(),
5952 };
5953 if let Some(referenced) = parse_re_marker(&body_str) {
5954 let matched = emitted_ids.contains(&referenced)
5957 || emitted_ids.iter().any(|full| full.starts_with(&referenced));
5958 if matched {
5959 eprintln!(
5960 "wire reactor: skip {} from {} — chain-depth (reply to our re:{})",
5961 ev.event_id, ev.peer, referenced
5962 );
5963 return Ok(false);
5964 }
5965 }
5966 }
5967
5968 if max_per_minute > 0 {
5970 let now = Instant::now();
5971 let win = peer_dispatch_log.entry(ev.peer.clone()).or_default();
5972 while let Some(&front) = win.front() {
5973 if now.duration_since(front) > Duration::from_secs(60) {
5974 win.pop_front();
5975 } else {
5976 break;
5977 }
5978 }
5979 if win.len() as u32 >= max_per_minute {
5980 eprintln!(
5981 "wire reactor: skip {} from {} — rate-limit ({}/min reached)",
5982 ev.event_id, ev.peer, max_per_minute
5983 );
5984 return Ok(false);
5985 }
5986 win.push_back(now);
5987 }
5988
5989 if dry_run {
5990 println!("{}", serde_json::to_string(&ev.raw)?);
5991 return Ok(true);
5992 }
5993
5994 let mut child = Command::new("sh")
5995 .arg("-c")
5996 .arg(on_event)
5997 .stdin(Stdio::piped())
5998 .stdout(Stdio::inherit())
5999 .stderr(Stdio::inherit())
6000 .env("WIRE_EVENT_PEER", &ev.peer)
6001 .env("WIRE_EVENT_ID", &ev.event_id)
6002 .env("WIRE_EVENT_KIND", &ev.kind)
6003 .spawn()
6004 .with_context(|| format!("spawning reactor handler: {on_event}"))?;
6005 if let Some(mut stdin) = child.stdin.take() {
6006 let body = serde_json::to_vec(&ev.raw)?;
6007 let _ = stdin.write_all(&body);
6008 let _ = stdin.write_all(b"\n");
6009 }
6010 std::mem::drop(child);
6011 Ok(true)
6012 };
6013
6014 let scan_outbox = |emitted_ids: &mut HashSet<String>,
6016 outbox_cursors: &mut HashMap<String, u64>|
6017 -> Result<usize> {
6018 if !outbox_dir.exists() {
6019 return Ok(0);
6020 }
6021 let mut added = 0;
6022 let mut new_ids: Vec<String> = Vec::new();
6023 for entry in std::fs::read_dir(&outbox_dir)?.flatten() {
6024 let path = entry.path();
6025 if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
6026 continue;
6027 }
6028 let peer = match path.file_stem().and_then(|s| s.to_str()) {
6029 Some(s) => s.to_string(),
6030 None => continue,
6031 };
6032 let cur_len = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
6033 let start = *outbox_cursors.get(&peer).unwrap_or(&0);
6034 if cur_len <= start {
6035 outbox_cursors.insert(peer, start);
6036 continue;
6037 }
6038 let body = std::fs::read_to_string(&path).unwrap_or_default();
6039 let tail = &body[start as usize..];
6040 for line in tail.lines() {
6041 if let Ok(v) = serde_json::from_str::<Value>(line)
6042 && let Some(eid) = v.get("event_id").and_then(Value::as_str)
6043 && emitted_ids.insert(eid.to_string())
6044 {
6045 new_ids.push(eid.to_string());
6046 added += 1;
6047 }
6048 }
6049 outbox_cursors.insert(peer, cur_len);
6050 }
6051 if !new_ids.is_empty() {
6052 let mut all: Vec<String> = emitted_ids.iter().cloned().collect();
6054 if all.len() > 500 {
6055 all.sort();
6056 let drop_n = all.len() - 500;
6057 let dropped: HashSet<String> = all.iter().take(drop_n).cloned().collect();
6058 emitted_ids.retain(|x| !dropped.contains(x));
6059 all = emitted_ids.iter().cloned().collect();
6060 }
6061 let _ = std::fs::write(&emitted_path, all.join("\n") + "\n");
6062 }
6063 Ok(added)
6064 };
6065
6066 let sweep = |watcher: &mut InboxWatcher,
6067 emitted_ids: &mut HashSet<String>,
6068 outbox_cursors: &mut HashMap<String, u64>,
6069 peer_dispatch_log: &mut HashMap<String, VecDeque<Instant>>|
6070 -> Result<usize> {
6071 let _ = scan_outbox(emitted_ids, outbox_cursors);
6073
6074 let events = watcher.poll()?;
6075 let mut fired = 0usize;
6076 for ev in &events {
6077 match dispatch(ev, peer_dispatch_log, emitted_ids) {
6078 Ok(true) => fired += 1,
6079 Ok(false) => {}
6080 Err(e) => eprintln!("wire reactor: handler error for {}: {e}", ev.event_id),
6081 }
6082 }
6083 watcher.save_cursors(&cursor_path)?;
6084 Ok(fired)
6085 };
6086
6087 if once {
6088 sweep(
6089 &mut watcher,
6090 &mut emitted_ids,
6091 &mut outbox_cursors,
6092 &mut peer_dispatch_log,
6093 )?;
6094 return Ok(());
6095 }
6096 let interval = std::time::Duration::from_secs(interval_secs.max(1));
6097 loop {
6098 if let Err(e) = sweep(
6099 &mut watcher,
6100 &mut emitted_ids,
6101 &mut outbox_cursors,
6102 &mut peer_dispatch_log,
6103 ) {
6104 eprintln!("wire reactor: sweep error: {e}");
6105 }
6106 std::thread::sleep(interval);
6107 }
6108}
6109
6110fn parse_re_marker(body: &str) -> Option<String> {
6113 let needle = "(re:";
6114 let i = body.find(needle)?;
6115 let rest = &body[i + needle.len()..];
6116 let end = rest.find(')')?;
6117 let id = rest[..end].trim().to_string();
6118 if id.is_empty() {
6119 return None;
6120 }
6121 Some(id)
6122}
6123
6124fn cmd_notify(
6127 interval_secs: u64,
6128 peer_filter: Option<&str>,
6129 once: bool,
6130 as_json: bool,
6131) -> Result<()> {
6132 use crate::inbox_watch::InboxWatcher;
6133 let cursor_path = config::state_dir()?.join("notify.cursor");
6134 let mut watcher = InboxWatcher::from_cursor_file(&cursor_path)?;
6135
6136 let sweep = |watcher: &mut InboxWatcher| -> Result<()> {
6137 let events = watcher.poll()?;
6138 for ev in events {
6139 if let Some(p) = peer_filter
6140 && ev.peer != p
6141 {
6142 continue;
6143 }
6144 if as_json {
6145 println!("{}", serde_json::to_string(&ev)?);
6146 } else {
6147 os_notify_inbox_event(&ev);
6148 }
6149 }
6150 watcher.save_cursors(&cursor_path)?;
6151 Ok(())
6152 };
6153
6154 if once {
6155 return sweep(&mut watcher);
6156 }
6157
6158 let interval = std::time::Duration::from_secs(interval_secs.max(1));
6159 loop {
6160 if let Err(e) = sweep(&mut watcher) {
6161 eprintln!("wire notify: sweep error: {e}");
6162 }
6163 std::thread::sleep(interval);
6164 }
6165}
6166
6167fn os_notify_inbox_event(ev: &crate::inbox_watch::InboxEvent) {
6168 let title = if ev.verified {
6169 format!("wire ← {}", ev.peer)
6170 } else {
6171 format!("wire ← {} (UNVERIFIED)", ev.peer)
6172 };
6173 let body = format!("{}: {}", ev.kind, ev.body_preview);
6174 crate::os_notify::toast(&title, &body);
6175}
6176
6177#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
6178fn os_toast(title: &str, body: &str) {
6179 eprintln!("[wire notify] {title}\n {body}");
6180}
6181
6182