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 #[arg(long, conflicts_with = "json")]
61 short: bool,
62 #[arg(long, conflicts_with_all = ["json", "short"])]
65 colored: bool,
66 },
67 Peers {
69 #[arg(long)]
70 json: bool,
71 },
72 Send {
80 peer: String,
82 kind_or_body: String,
87 body: Option<String>,
91 #[arg(long)]
93 deadline: Option<String>,
94 #[arg(long)]
96 json: bool,
97 },
98 Tail {
100 peer: Option<String>,
102 #[arg(long)]
104 json: bool,
105 #[arg(long, default_value_t = 0)]
107 limit: usize,
108 },
109 Monitor {
120 #[arg(long)]
122 peer: Option<String>,
123 #[arg(long)]
125 json: bool,
126 #[arg(long)]
129 include_handshake: bool,
130 #[arg(long, default_value_t = 500)]
132 interval_ms: u64,
133 #[arg(long, default_value_t = 0)]
135 replay: usize,
136 },
137 Verify {
139 path: String,
141 #[arg(long)]
143 json: bool,
144 },
145 Mcp,
149 RelayServer {
151 #[arg(long, default_value = "127.0.0.1:8770")]
153 bind: String,
154 #[arg(long)]
162 local_only: bool,
163 #[arg(long)]
169 uds: Option<std::path::PathBuf>,
170 },
171 BindRelay {
180 url: String,
182 #[arg(long)]
187 migrate_pinned: bool,
188 #[arg(long)]
189 json: bool,
190 },
191 AddPeerSlot {
194 handle: String,
196 url: String,
198 slot_id: String,
200 slot_token: String,
202 #[arg(long)]
203 json: bool,
204 },
205 Push {
207 peer: Option<String>,
209 #[arg(long)]
210 json: bool,
211 },
212 Pull {
214 #[arg(long)]
215 json: bool,
216 },
217 Status {
220 #[arg(long)]
222 peer: Option<String>,
223 #[arg(long)]
224 json: bool,
225 },
226 Responder {
228 #[command(subcommand)]
229 command: ResponderCommand,
230 },
231 Pin {
234 card_file: String,
236 #[arg(long)]
237 json: bool,
238 },
239 RotateSlot {
250 #[arg(long)]
253 no_announce: bool,
254 #[arg(long)]
255 json: bool,
256 },
257 ForgetPeer {
261 handle: String,
263 #[arg(long)]
265 purge: bool,
266 #[arg(long)]
267 json: bool,
268 },
269 Daemon {
273 #[arg(long, default_value_t = 5)]
275 interval: u64,
276 #[arg(long)]
278 once: bool,
279 #[arg(long)]
280 json: bool,
281 },
282 PairHost {
287 #[arg(long)]
289 relay: String,
290 #[arg(long)]
294 yes: bool,
295 #[arg(long, default_value_t = 300)]
297 timeout: u64,
298 #[arg(long)]
304 detach: bool,
305 #[arg(long)]
307 json: bool,
308 },
309 #[command(alias = "join")]
313 PairJoin {
314 code_phrase: String,
316 #[arg(long)]
318 relay: String,
319 #[arg(long)]
320 yes: bool,
321 #[arg(long, default_value_t = 300)]
322 timeout: u64,
323 #[arg(long)]
325 detach: bool,
326 #[arg(long)]
328 json: bool,
329 },
330 PairConfirm {
334 code_phrase: String,
336 digits: String,
338 #[arg(long)]
340 json: bool,
341 },
342 PairList {
344 #[arg(long)]
346 json: bool,
347 #[arg(long)]
351 watch: bool,
352 #[arg(long, default_value_t = 1)]
354 watch_interval: u64,
355 },
356 PairCancel {
358 code_phrase: String,
359 #[arg(long)]
360 json: bool,
361 },
362 PairWatch {
372 code_phrase: String,
373 #[arg(long, default_value = "sas_ready")]
375 status: String,
376 #[arg(long, default_value_t = 300)]
378 timeout: u64,
379 #[arg(long)]
381 json: bool,
382 },
383 Pair {
392 handle: String,
395 #[arg(long)]
398 code: Option<String>,
399 #[arg(long, default_value = "https://wireup.net")]
401 relay: String,
402 #[arg(long)]
404 yes: bool,
405 #[arg(long, default_value_t = 300)]
407 timeout: u64,
408 #[arg(long)]
411 no_setup: bool,
412 #[arg(long)]
417 detach: bool,
418 },
419 PairAbandon {
425 code_phrase: String,
427 #[arg(long, default_value = "https://wireup.net")]
429 relay: String,
430 },
431 PairAccept {
437 peer: String,
439 #[arg(long)]
441 json: bool,
442 },
443 PairReject {
450 peer: String,
452 #[arg(long)]
454 json: bool,
455 },
456 PairListInbound {
462 #[arg(long)]
464 json: bool,
465 },
466 #[command(subcommand)]
476 Session(SessionCommand),
477 Identity {
482 #[command(subcommand)]
483 cmd: IdentityCommand,
484 },
485 #[command(subcommand)]
490 Mesh(MeshCommand),
491 Setup {
496 #[arg(long)]
498 apply: bool,
499 },
500 Whois {
504 handle: Option<String>,
506 #[arg(long)]
507 json: bool,
508 #[arg(long)]
511 relay: Option<String>,
512 },
513 Add {
519 handle: String,
522 #[arg(long)]
524 relay: Option<String>,
525 #[arg(long)]
533 local_sister: bool,
534 #[arg(long)]
535 json: bool,
536 },
537 Up {
547 handle: String,
550 #[arg(long)]
552 name: Option<String>,
553 #[arg(long)]
554 json: bool,
555 },
556 Doctor {
563 #[arg(long)]
565 json: bool,
566 #[arg(long, default_value_t = 5)]
568 recent_rejections: usize,
569 },
570 Upgrade {
575 #[arg(long)]
578 check: bool,
579 #[arg(long)]
580 json: bool,
581 },
582 Service {
587 #[command(subcommand)]
588 action: ServiceAction,
589 },
590 Diag {
595 #[command(subcommand)]
596 action: DiagAction,
597 },
598 Claim {
602 nick: String,
603 #[arg(long)]
605 relay: Option<String>,
606 #[arg(long)]
608 public_url: Option<String>,
609 #[arg(long)]
617 hidden: bool,
618 #[arg(long)]
619 json: bool,
620 },
621 Profile {
631 #[command(subcommand)]
632 action: ProfileAction,
633 },
634 Invite {
638 #[arg(long, default_value = "https://wireup.net")]
640 relay: String,
641 #[arg(long, default_value_t = 86_400)]
643 ttl: u64,
644 #[arg(long, default_value_t = 1)]
647 uses: u32,
648 #[arg(long)]
652 share: bool,
653 #[arg(long)]
655 json: bool,
656 },
657 Accept {
660 url: String,
662 #[arg(long)]
664 json: bool,
665 },
666 Reactor {
672 #[arg(long)]
674 on_event: String,
675 #[arg(long)]
677 peer: Option<String>,
678 #[arg(long)]
680 kind: Option<String>,
681 #[arg(long, default_value_t = true)]
683 verified_only: bool,
684 #[arg(long, default_value_t = 2)]
686 interval: u64,
687 #[arg(long)]
689 once: bool,
690 #[arg(long)]
692 dry_run: bool,
693 #[arg(long, default_value_t = 6)]
697 max_per_minute: u32,
698 #[arg(long, default_value_t = 1)]
702 max_chain_depth: u32,
703 },
704 Notify {
709 #[arg(long, default_value_t = 2)]
711 interval: u64,
712 #[arg(long)]
714 peer: Option<String>,
715 #[arg(long)]
717 once: bool,
718 #[arg(long)]
722 json: bool,
723 },
724}
725
726#[derive(Subcommand, Debug)]
727pub enum DiagAction {
728 Tail {
730 #[arg(long, default_value_t = 20)]
731 limit: usize,
732 #[arg(long)]
733 json: bool,
734 },
735 Enable,
738 Disable,
740 Status {
742 #[arg(long)]
743 json: bool,
744 },
745}
746
747#[derive(Subcommand, Debug)]
748pub enum IdentityCommand {
749 Rename {
759 #[arg(long)]
763 name: Option<String>,
764 #[arg(long)]
767 emoji: Option<String>,
768 #[arg(long, conflicts_with_all = ["name", "emoji"])]
771 clear: bool,
772 #[arg(long, conflicts_with_all = ["name", "emoji", "clear"])]
776 random: bool,
777 #[arg(long)]
778 json: bool,
779 },
780 Show {
783 #[arg(long)]
784 json: bool,
785 },
786 List {
791 #[arg(long)]
792 json: bool,
793 },
794 Publish {
800 nick: String,
802 #[arg(long)]
805 relay: Option<String>,
806 #[arg(long, alias = "public")]
809 public_url: Option<String>,
810 #[arg(long)]
814 hidden: bool,
815 #[arg(long)]
816 json: bool,
817 },
818 Destroy {
822 name: String,
824 #[arg(long)]
826 force: bool,
827 #[arg(long)]
828 json: bool,
829 },
830 Create {
842 #[arg(long)]
845 name: Option<String>,
846 #[arg(long, conflicts_with = "local")]
849 anonymous: bool,
850 #[arg(long)]
853 local: bool,
854 #[arg(long)]
855 json: bool,
856 },
857 Persist {
862 name: String,
864 #[arg(long = "as", value_name = "NEW_NAME")]
866 as_name: Option<String>,
867 #[arg(long)]
868 json: bool,
869 },
870 Demote {
880 name: String,
882 #[arg(long)]
883 json: bool,
884 },
885}
886
887#[derive(Subcommand, Debug)]
888pub enum SessionCommand {
889 New {
897 name: Option<String>,
899 #[arg(long, default_value = "https://wireup.net")]
901 relay: String,
902 #[arg(long)]
909 with_local: bool,
910 #[arg(long, default_value = "http://127.0.0.1:8771")]
914 local_relay: String,
915 #[arg(long)]
922 with_lan: bool,
923 #[arg(long)]
927 lan_relay: Option<String>,
928 #[arg(long)]
935 with_uds: bool,
936 #[arg(long)]
940 uds_socket: Option<std::path::PathBuf>,
941 #[arg(long)]
944 no_daemon: bool,
945 #[arg(long)]
953 local_only: bool,
954 #[arg(long)]
956 json: bool,
957 },
958 List {
961 #[arg(long)]
962 json: bool,
963 },
964 ListLocal {
970 #[arg(long)]
971 json: bool,
972 },
973 PairAllLocal {
989 #[arg(long, default_value_t = 1)]
994 settle_secs: u64,
995 #[arg(long, default_value = "https://wireup.net")]
1000 federation_relay: String,
1001 #[arg(long)]
1002 json: bool,
1003 },
1004 MeshStatus {
1018 #[arg(long, default_value_t = 300)]
1023 stale_secs: u64,
1024 #[arg(long)]
1025 json: bool,
1026 },
1027 Env {
1031 name: Option<String>,
1033 #[arg(long)]
1034 json: bool,
1035 },
1036 Current {
1040 #[arg(long)]
1041 json: bool,
1042 },
1043 Destroy {
1047 name: String,
1048 #[arg(long)]
1050 force: bool,
1051 #[arg(long)]
1052 json: bool,
1053 },
1054}
1055
1056#[derive(Subcommand, Debug)]
1061pub enum MeshCommand {
1062 Status {
1065 #[arg(long, default_value_t = 300)]
1067 stale_secs: u64,
1068 #[arg(long)]
1069 json: bool,
1070 },
1071 Broadcast {
1090 #[arg(long, default_value = "claim")]
1093 kind: String,
1094 #[arg(long, default_value = "local")]
1096 scope: String,
1097 #[arg(long)]
1099 exclude: Vec<String>,
1100 #[arg(long)]
1104 noreply: bool,
1105 body: String,
1107 #[arg(long)]
1108 json: bool,
1109 },
1110 Role {
1119 #[command(subcommand)]
1120 action: MeshRoleAction,
1121 },
1122 Route {
1138 role: String,
1140 #[arg(long, default_value = "round-robin")]
1142 strategy: String,
1143 #[arg(long)]
1145 exclude: Vec<String>,
1146 #[arg(long, default_value = "claim")]
1149 kind: String,
1150 body: String,
1152 #[arg(long)]
1153 json: bool,
1154 },
1155}
1156
1157#[derive(Subcommand, Debug)]
1159pub enum MeshRoleAction {
1160 Set {
1165 role: String,
1166 #[arg(long)]
1167 json: bool,
1168 },
1169 Get {
1172 peer: Option<String>,
1173 #[arg(long)]
1174 json: bool,
1175 },
1176 List {
1179 #[arg(long)]
1180 json: bool,
1181 },
1182 Clear {
1185 #[arg(long)]
1186 json: bool,
1187 },
1188}
1189
1190#[derive(Subcommand, Debug)]
1191pub enum ServiceAction {
1192 Install {
1202 #[arg(long)]
1204 local_relay: bool,
1205 #[arg(long)]
1206 json: bool,
1207 },
1208 Uninstall {
1212 #[arg(long)]
1214 local_relay: bool,
1215 #[arg(long)]
1216 json: bool,
1217 },
1218 Status {
1220 #[arg(long)]
1222 local_relay: bool,
1223 #[arg(long)]
1224 json: bool,
1225 },
1226}
1227
1228#[derive(Subcommand, Debug)]
1229pub enum ResponderCommand {
1230 Set {
1232 status: String,
1234 #[arg(long)]
1236 reason: Option<String>,
1237 #[arg(long)]
1239 json: bool,
1240 },
1241 Get {
1243 peer: Option<String>,
1245 #[arg(long)]
1247 json: bool,
1248 },
1249}
1250
1251#[derive(Subcommand, Debug)]
1252pub enum ProfileAction {
1253 Set {
1257 field: String,
1258 value: String,
1259 #[arg(long)]
1260 json: bool,
1261 },
1262 Get {
1264 #[arg(long)]
1265 json: bool,
1266 },
1267 Clear {
1269 field: String,
1270 #[arg(long)]
1271 json: bool,
1272 },
1273}
1274
1275pub fn run() -> Result<()> {
1277 crate::session::maybe_adopt_session_wire_home("cli");
1288 let cli = Cli::parse();
1289 match cli.command {
1290 Command::Init {
1291 handle,
1292 name,
1293 relay,
1294 json,
1295 } => cmd_init(&handle, name.as_deref(), relay.as_deref(), json),
1296 Command::Status { peer, json } => {
1297 if let Some(peer) = peer {
1298 cmd_status_peer(&peer, json)
1299 } else {
1300 cmd_status(json)
1301 }
1302 }
1303 Command::Whoami {
1304 json,
1305 short,
1306 colored,
1307 } => cmd_whoami(json, short, colored),
1308 Command::Peers { json } => cmd_peers(json),
1309 Command::Send {
1310 peer,
1311 kind_or_body,
1312 body,
1313 deadline,
1314 json,
1315 } => {
1316 let (kind, body) = match body {
1319 Some(real_body) => (kind_or_body, real_body),
1320 None => ("claim".to_string(), kind_or_body),
1321 };
1322 cmd_send(&peer, &kind, &body, deadline.as_deref(), json)
1323 }
1324 Command::Tail { peer, json, limit } => cmd_tail(peer.as_deref(), json, limit),
1325 Command::Monitor {
1326 peer,
1327 json,
1328 include_handshake,
1329 interval_ms,
1330 replay,
1331 } => cmd_monitor(
1332 peer.as_deref(),
1333 json,
1334 include_handshake,
1335 interval_ms,
1336 replay,
1337 ),
1338 Command::Verify { path, json } => cmd_verify(&path, json),
1339 Command::Responder { command } => match command {
1340 ResponderCommand::Set {
1341 status,
1342 reason,
1343 json,
1344 } => cmd_responder_set(&status, reason.as_deref(), json),
1345 ResponderCommand::Get { peer, json } => cmd_responder_get(peer.as_deref(), json),
1346 },
1347 Command::Mcp => cmd_mcp(),
1348 Command::RelayServer {
1349 bind,
1350 local_only,
1351 uds,
1352 } => cmd_relay_server(&bind, local_only, uds.as_deref()),
1353 Command::BindRelay {
1354 url,
1355 migrate_pinned,
1356 json,
1357 } => cmd_bind_relay(&url, migrate_pinned, json),
1358 Command::AddPeerSlot {
1359 handle,
1360 url,
1361 slot_id,
1362 slot_token,
1363 json,
1364 } => cmd_add_peer_slot(&handle, &url, &slot_id, &slot_token, json),
1365 Command::Push { peer, json } => cmd_push(peer.as_deref(), json),
1366 Command::Pull { json } => cmd_pull(json),
1367 Command::Pin { card_file, json } => cmd_pin(&card_file, json),
1368 Command::RotateSlot { no_announce, json } => cmd_rotate_slot(no_announce, json),
1369 Command::ForgetPeer {
1370 handle,
1371 purge,
1372 json,
1373 } => cmd_forget_peer(&handle, purge, json),
1374 Command::Daemon {
1375 interval,
1376 once,
1377 json,
1378 } => cmd_daemon(interval, once, json),
1379 Command::PairHost {
1380 relay,
1381 yes,
1382 timeout,
1383 detach,
1384 json,
1385 } => {
1386 if detach {
1387 cmd_pair_host_detach(&relay, json)
1388 } else {
1389 cmd_pair_host(&relay, yes, timeout)
1390 }
1391 }
1392 Command::PairJoin {
1393 code_phrase,
1394 relay,
1395 yes,
1396 timeout,
1397 detach,
1398 json,
1399 } => {
1400 if detach {
1401 cmd_pair_join_detach(&code_phrase, &relay, json)
1402 } else {
1403 cmd_pair_join(&code_phrase, &relay, yes, timeout)
1404 }
1405 }
1406 Command::PairConfirm {
1407 code_phrase,
1408 digits,
1409 json,
1410 } => cmd_pair_confirm(&code_phrase, &digits, json),
1411 Command::PairList {
1412 json,
1413 watch,
1414 watch_interval,
1415 } => cmd_pair_list(json, watch, watch_interval),
1416 Command::PairCancel { code_phrase, json } => cmd_pair_cancel(&code_phrase, json),
1417 Command::PairWatch {
1418 code_phrase,
1419 status,
1420 timeout,
1421 json,
1422 } => cmd_pair_watch(&code_phrase, &status, timeout, json),
1423 Command::Pair {
1424 handle,
1425 code,
1426 relay,
1427 yes,
1428 timeout,
1429 no_setup,
1430 detach,
1431 } => {
1432 if handle.contains('@') && code.is_none() {
1439 cmd_pair_megacommand(&handle, Some(&relay), timeout, false)
1440 } else if detach {
1441 cmd_pair_detach(&handle, code.as_deref(), &relay)
1442 } else {
1443 cmd_pair(&handle, code.as_deref(), &relay, yes, timeout, no_setup)
1444 }
1445 }
1446 Command::PairAbandon { code_phrase, relay } => cmd_pair_abandon(&code_phrase, &relay),
1447 Command::PairAccept { peer, json } => cmd_pair_accept(&peer, json),
1448 Command::PairReject { peer, json } => cmd_pair_reject(&peer, json),
1449 Command::PairListInbound { json } => cmd_pair_list_inbound(json),
1450 Command::Session(cmd) => cmd_session(cmd),
1451 Command::Identity { cmd } => cmd_identity(cmd),
1452 Command::Mesh(cmd) => cmd_mesh(cmd),
1453 Command::Invite {
1454 relay,
1455 ttl,
1456 uses,
1457 share,
1458 json,
1459 } => cmd_invite(&relay, ttl, uses, share, json),
1460 Command::Accept { url, json } => cmd_accept(&url, json),
1461 Command::Whois {
1462 handle,
1463 json,
1464 relay,
1465 } => cmd_whois(handle.as_deref(), json, relay.as_deref()),
1466 Command::Add {
1467 handle,
1468 relay,
1469 local_sister,
1470 json,
1471 } => cmd_add(&handle, relay.as_deref(), local_sister, json),
1472 Command::Up { handle, name, json } => cmd_up(&handle, name.as_deref(), json),
1473 Command::Doctor {
1474 json,
1475 recent_rejections,
1476 } => cmd_doctor(json, recent_rejections),
1477 Command::Upgrade { check, json } => cmd_upgrade(check, json),
1478 Command::Service { action } => cmd_service(action),
1479 Command::Diag { action } => cmd_diag(action),
1480 Command::Claim {
1481 nick,
1482 relay,
1483 public_url,
1484 hidden,
1485 json,
1486 } => cmd_claim(&nick, relay.as_deref(), public_url.as_deref(), hidden, json),
1487 Command::Profile { action } => cmd_profile(action),
1488 Command::Setup { apply } => cmd_setup(apply),
1489 Command::Reactor {
1490 on_event,
1491 peer,
1492 kind,
1493 verified_only,
1494 interval,
1495 once,
1496 dry_run,
1497 max_per_minute,
1498 max_chain_depth,
1499 } => cmd_reactor(
1500 &on_event,
1501 peer.as_deref(),
1502 kind.as_deref(),
1503 verified_only,
1504 interval,
1505 once,
1506 dry_run,
1507 max_per_minute,
1508 max_chain_depth,
1509 ),
1510 Command::Notify {
1511 interval,
1512 peer,
1513 once,
1514 json,
1515 } => cmd_notify(interval, peer.as_deref(), once, json),
1516 }
1517}
1518
1519fn cmd_init(handle: &str, name: Option<&str>, relay: Option<&str>, as_json: bool) -> Result<()> {
1522 if !handle
1523 .chars()
1524 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
1525 {
1526 bail!("handle must be ASCII alphanumeric / '-' / '_' (got {handle:?})");
1527 }
1528 if config::is_initialized()? {
1529 bail!(
1530 "already initialized — config exists at {:?}. Delete it first if you want a fresh identity.",
1531 config::config_dir()?
1532 );
1533 }
1534
1535 config::ensure_dirs()?;
1536 let (sk_seed, pk_bytes) = generate_keypair();
1537 config::write_private_key(&sk_seed)?;
1538
1539 let card = build_agent_card(handle, &pk_bytes, name, None, None);
1540 let signed = sign_agent_card(&card, &sk_seed);
1541 config::write_agent_card(&signed)?;
1542
1543 let mut trust = empty_trust();
1544 add_self_to_trust(&mut trust, handle, &pk_bytes);
1545 config::write_trust(&trust)?;
1546
1547 let fp = fingerprint(&pk_bytes);
1548 let key_id = make_key_id(handle, &pk_bytes);
1549
1550 let mut relay_info: Option<(String, String)> = None;
1552 if let Some(url) = relay {
1553 let normalized = url.trim_end_matches('/');
1554 let client = crate::relay_client::RelayClient::new(normalized);
1555 client.check_healthz()?;
1556 let alloc = client.allocate_slot(Some(handle))?;
1557 let mut state = config::read_relay_state()?;
1558 state["self"] = json!({
1559 "relay_url": normalized,
1560 "slot_id": alloc.slot_id.clone(),
1561 "slot_token": alloc.slot_token,
1562 });
1563 config::write_relay_state(&state)?;
1564 relay_info = Some((normalized.to_string(), alloc.slot_id));
1565 }
1566
1567 let did_str = crate::agent_card::did_for_with_key(handle, &pk_bytes);
1568 if as_json {
1569 let mut out = json!({
1570 "did": did_str.clone(),
1571 "fingerprint": fp,
1572 "key_id": key_id,
1573 "config_dir": config::config_dir()?.to_string_lossy(),
1574 });
1575 if let Some((url, slot_id)) = &relay_info {
1576 out["relay_url"] = json!(url);
1577 out["slot_id"] = json!(slot_id);
1578 }
1579 println!("{}", serde_json::to_string(&out)?);
1580 } else {
1581 println!("generated {did_str} (ed25519:{key_id})");
1582 println!(
1583 "config written to {}",
1584 config::config_dir()?.to_string_lossy()
1585 );
1586 if let Some((url, slot_id)) = &relay_info {
1587 println!("bound to relay {url} (slot {slot_id})");
1588 println!();
1589 println!(
1590 "next step: `wire pair-host --relay {url}` to print a code phrase for a peer."
1591 );
1592 } else {
1593 println!();
1594 println!(
1595 "next step: `wire pair-host --relay <url>` to bind a relay + open a pair-slot."
1596 );
1597 }
1598 }
1599 Ok(())
1600}
1601
1602fn cmd_status(as_json: bool) -> Result<()> {
1605 let initialized = config::is_initialized()?;
1606
1607 let mut summary = json!({
1608 "initialized": initialized,
1609 });
1610
1611 if initialized {
1612 let card = config::read_agent_card()?;
1613 let did = card
1614 .get("did")
1615 .and_then(Value::as_str)
1616 .unwrap_or("")
1617 .to_string();
1618 let handle = card
1622 .get("handle")
1623 .and_then(Value::as_str)
1624 .map(str::to_string)
1625 .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
1626 let pk_b64 = card
1627 .get("verify_keys")
1628 .and_then(Value::as_object)
1629 .and_then(|m| m.values().next())
1630 .and_then(|v| v.get("key"))
1631 .and_then(Value::as_str)
1632 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
1633 let pk_bytes = crate::signing::b64decode(pk_b64)?;
1634 summary["did"] = json!(did);
1635 summary["handle"] = json!(handle);
1636 summary["fingerprint"] = json!(fingerprint(&pk_bytes));
1637 summary["capabilities"] = card
1638 .get("capabilities")
1639 .cloned()
1640 .unwrap_or_else(|| json!([]));
1641
1642 let trust = config::read_trust()?;
1643 let relay_state_for_tier =
1644 config::read_relay_state().unwrap_or_else(|_| json!({"peers": {}}));
1645 let mut peers = Vec::new();
1646 if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
1647 for (peer_handle, _agent) in agents {
1648 if peer_handle == &handle {
1649 continue; }
1651 peers.push(json!({
1656 "handle": peer_handle,
1657 "tier": effective_peer_tier(&trust, &relay_state_for_tier, peer_handle),
1658 }));
1659 }
1660 }
1661 summary["peers"] = json!(peers);
1662
1663 let relay_state = config::read_relay_state()?;
1664 summary["self_relay"] = relay_state.get("self").cloned().unwrap_or(Value::Null);
1665 if !summary["self_relay"].is_null() {
1666 if let Some(obj) = summary["self_relay"].as_object_mut() {
1668 obj.remove("slot_token");
1669 }
1670 }
1671 summary["peer_slots_count"] = json!(
1672 relay_state
1673 .get("peers")
1674 .and_then(Value::as_object)
1675 .map(|m| m.len())
1676 .unwrap_or(0)
1677 );
1678
1679 let outbox = config::outbox_dir()?;
1681 let inbox = config::inbox_dir()?;
1682 summary["outbox"] = json!(scan_jsonl_dir(&outbox)?);
1683 summary["inbox"] = json!(scan_jsonl_dir(&inbox)?);
1684
1685 let snap = crate::ensure_up::daemon_liveness();
1691 let mut daemon = json!({
1692 "running": snap.pidfile_alive,
1693 "pid": snap.pidfile_pid,
1694 "all_running_pids": snap.pgrep_pids,
1695 "orphans": snap.orphan_pids,
1696 });
1697 if let crate::ensure_up::PidRecord::Json(d) = &snap.record {
1698 daemon["version"] = json!(d.version);
1699 daemon["bin_path"] = json!(d.bin_path);
1700 daemon["did"] = json!(d.did);
1701 daemon["relay_url"] = json!(d.relay_url);
1702 daemon["started_at"] = json!(d.started_at);
1703 daemon["schema"] = json!(d.schema);
1704 if d.version != env!("CARGO_PKG_VERSION") {
1705 daemon["version_mismatch"] = json!({
1706 "daemon": d.version.clone(),
1707 "cli": env!("CARGO_PKG_VERSION"),
1708 });
1709 }
1710 } else if matches!(snap.record, crate::ensure_up::PidRecord::LegacyInt(_)) {
1711 daemon["pidfile_form"] = json!("legacy-int");
1712 daemon["version_mismatch"] = json!({
1713 "daemon": "<pre-0.5.11>",
1714 "cli": env!("CARGO_PKG_VERSION"),
1715 });
1716 }
1717 summary["daemon"] = daemon;
1718
1719 let pending = crate::pending_pair::list_pending().unwrap_or_default();
1721 let mut counts: std::collections::BTreeMap<String, u32> = Default::default();
1722 for p in &pending {
1723 *counts.entry(p.status.clone()).or_default() += 1;
1724 }
1725 let pending_inbound =
1727 crate::pending_inbound_pair::list_pending_inbound().unwrap_or_default();
1728 let inbound_handles: Vec<&str> = pending_inbound
1729 .iter()
1730 .map(|p| p.peer_handle.as_str())
1731 .collect();
1732 summary["pending_pairs"] = json!({
1733 "total": pending.len(),
1734 "by_status": counts,
1735 "inbound_count": pending_inbound.len(),
1736 "inbound_handles": inbound_handles,
1737 });
1738 }
1739
1740 if as_json {
1741 println!("{}", serde_json::to_string(&summary)?);
1742 } else if !initialized {
1743 println!("not initialized — run `wire init <handle>` first");
1744 } else {
1745 println!("did: {}", summary["did"].as_str().unwrap_or("?"));
1746 println!(
1747 "fingerprint: {}",
1748 summary["fingerprint"].as_str().unwrap_or("?")
1749 );
1750 println!("capabilities: {}", summary["capabilities"]);
1751 if !summary["self_relay"].is_null() {
1752 println!(
1753 "self relay: {} (slot {})",
1754 summary["self_relay"]["relay_url"].as_str().unwrap_or("?"),
1755 summary["self_relay"]["slot_id"].as_str().unwrap_or("?")
1756 );
1757 } else {
1758 println!("self relay: (not bound — run `wire pair-host --relay <url>` to bind)");
1759 }
1760 println!(
1761 "peers: {}",
1762 summary["peers"].as_array().map(|a| a.len()).unwrap_or(0)
1763 );
1764 for p in summary["peers"].as_array().unwrap_or(&Vec::new()) {
1765 println!(
1766 " - {:<20} tier={}",
1767 p["handle"].as_str().unwrap_or(""),
1768 p["tier"].as_str().unwrap_or("?")
1769 );
1770 }
1771 println!(
1772 "outbox: {} file(s), {} event(s) queued",
1773 summary["outbox"]["files"].as_u64().unwrap_or(0),
1774 summary["outbox"]["events"].as_u64().unwrap_or(0)
1775 );
1776 println!(
1777 "inbox: {} file(s), {} event(s) received",
1778 summary["inbox"]["files"].as_u64().unwrap_or(0),
1779 summary["inbox"]["events"].as_u64().unwrap_or(0)
1780 );
1781 let daemon_running = summary["daemon"]["running"].as_bool().unwrap_or(false);
1782 let daemon_pid = summary["daemon"]["pid"]
1783 .as_u64()
1784 .map(|p| p.to_string())
1785 .unwrap_or_else(|| "—".to_string());
1786 let daemon_version = summary["daemon"]["version"].as_str().unwrap_or("");
1787 let version_suffix = if !daemon_version.is_empty() {
1788 format!(" v{daemon_version}")
1789 } else {
1790 String::new()
1791 };
1792 println!(
1793 "daemon: {} (pid {}{})",
1794 if daemon_running { "running" } else { "DOWN" },
1795 daemon_pid,
1796 version_suffix,
1797 );
1798 if let Some(mm) = summary["daemon"].get("version_mismatch") {
1800 println!(
1801 " !! version mismatch: daemon={} CLI={}. \
1802 run `wire upgrade` to swap atomically.",
1803 mm["daemon"].as_str().unwrap_or("?"),
1804 mm["cli"].as_str().unwrap_or("?"),
1805 );
1806 }
1807 if let Some(orphans) = summary["daemon"]["orphans"].as_array()
1808 && !orphans.is_empty()
1809 {
1810 let pids: Vec<String> = orphans
1811 .iter()
1812 .filter_map(|v| v.as_u64().map(|p| p.to_string()))
1813 .collect();
1814 println!(
1815 " !! orphan daemon process(es): pids {}. \
1816 pgrep saw them but pidfile didn't — likely stale process from \
1817 prior install. Multiple daemons race the relay cursor.",
1818 pids.join(", ")
1819 );
1820 }
1821 let pending_total = summary["pending_pairs"]["total"].as_u64().unwrap_or(0);
1822 let inbound_count = summary["pending_pairs"]["inbound_count"]
1823 .as_u64()
1824 .unwrap_or(0);
1825 if pending_total > 0 {
1826 print!("pending pairs: {pending_total}");
1827 if let Some(obj) = summary["pending_pairs"]["by_status"].as_object() {
1828 let parts: Vec<String> = obj
1829 .iter()
1830 .map(|(k, v)| format!("{}={}", k, v.as_u64().unwrap_or(0)))
1831 .collect();
1832 if !parts.is_empty() {
1833 print!(" ({})", parts.join(", "));
1834 }
1835 }
1836 println!();
1837 } else if inbound_count == 0 {
1838 println!("pending pairs: none");
1839 }
1840 if inbound_count > 0 {
1844 let handles: Vec<String> = summary["pending_pairs"]["inbound_handles"]
1845 .as_array()
1846 .map(|a| {
1847 a.iter()
1848 .filter_map(|v| v.as_str().map(str::to_string))
1849 .collect()
1850 })
1851 .unwrap_or_default();
1852 println!(
1853 "inbound pair requests ({inbound_count}): {} — `wire pair-list` to inspect, `wire pair-accept <peer>` to accept, `wire pair-reject <peer>` to refuse",
1854 handles.join(", "),
1855 );
1856 }
1857 }
1858 Ok(())
1859}
1860
1861fn scan_jsonl_dir(dir: &std::path::Path) -> Result<Value> {
1862 if !dir.exists() {
1863 return Ok(json!({"files": 0, "events": 0}));
1864 }
1865 let mut files = 0usize;
1866 let mut events = 0usize;
1867 for entry in std::fs::read_dir(dir)? {
1868 let path = entry?.path();
1869 if path.extension().map(|x| x == "jsonl").unwrap_or(false) {
1870 files += 1;
1871 if let Ok(body) = std::fs::read_to_string(&path) {
1872 events += body.lines().filter(|l| !l.trim().is_empty()).count();
1873 }
1874 }
1875 }
1876 Ok(json!({"files": files, "events": events}))
1877}
1878
1879fn responder_status_allowed(status: &str) -> bool {
1882 matches!(
1883 status,
1884 "online" | "offline" | "oauth_locked" | "rate_limited" | "degraded"
1885 )
1886}
1887
1888fn relay_slot_for(peer: Option<&str>) -> Result<(String, String, String, String)> {
1889 let state = config::read_relay_state()?;
1890 let (label, slot_info) = match peer {
1891 Some(peer) => (
1892 peer.to_string(),
1893 state
1894 .get("peers")
1895 .and_then(|p| p.get(peer))
1896 .ok_or_else(|| {
1897 anyhow!(
1898 "unknown peer {peer:?} in relay state — pair with them first:\n \
1899 wire add {peer}@wireup.net (or {peer}@<their-relay>)\n\
1900 (`wire peers` lists who you've already paired with.)"
1901 )
1902 })?,
1903 ),
1904 None => (
1905 "self".to_string(),
1906 state.get("self").filter(|v| !v.is_null()).ok_or_else(|| {
1907 anyhow!("self slot not bound — run `wire bind-relay <url>` first")
1908 })?,
1909 ),
1910 };
1911 let relay_url = slot_info["relay_url"]
1912 .as_str()
1913 .ok_or_else(|| anyhow!("{label} relay_url missing"))?
1914 .to_string();
1915 let slot_id = slot_info["slot_id"]
1916 .as_str()
1917 .ok_or_else(|| anyhow!("{label} slot_id missing"))?
1918 .to_string();
1919 let slot_token = slot_info["slot_token"]
1920 .as_str()
1921 .ok_or_else(|| anyhow!("{label} slot_token missing"))?
1922 .to_string();
1923 Ok((label, relay_url, slot_id, slot_token))
1924}
1925
1926fn cmd_responder_set(status: &str, reason: Option<&str>, as_json: bool) -> Result<()> {
1927 if !responder_status_allowed(status) {
1928 bail!("status must be one of: online, offline, oauth_locked, rate_limited, degraded");
1929 }
1930 let (_label, relay_url, slot_id, slot_token) = relay_slot_for(None)?;
1931 let now = time::OffsetDateTime::now_utc()
1932 .format(&time::format_description::well_known::Rfc3339)
1933 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
1934 let mut record = json!({
1935 "status": status,
1936 "set_at": now,
1937 });
1938 if let Some(reason) = reason {
1939 record["reason"] = json!(reason);
1940 }
1941 if status == "online" {
1942 record["last_success_at"] = json!(now);
1943 }
1944 let client = crate::relay_client::RelayClient::new(&relay_url);
1945 let saved = client.responder_health_set(&slot_id, &slot_token, &record)?;
1946 if as_json {
1947 println!("{}", serde_json::to_string(&saved)?);
1948 } else {
1949 let reason = saved
1950 .get("reason")
1951 .and_then(Value::as_str)
1952 .map(|r| format!(" — {r}"))
1953 .unwrap_or_default();
1954 println!(
1955 "responder {}{}",
1956 saved
1957 .get("status")
1958 .and_then(Value::as_str)
1959 .unwrap_or(status),
1960 reason
1961 );
1962 }
1963 Ok(())
1964}
1965
1966fn cmd_responder_get(peer: Option<&str>, as_json: bool) -> Result<()> {
1967 let (label, relay_url, slot_id, slot_token) = relay_slot_for(peer)?;
1968 let client = crate::relay_client::RelayClient::new(&relay_url);
1969 let health = client.responder_health_get(&slot_id, &slot_token)?;
1970 if as_json {
1971 println!(
1972 "{}",
1973 serde_json::to_string(&json!({
1974 "target": label,
1975 "responder_health": health,
1976 }))?
1977 );
1978 } else if health.is_null() {
1979 println!("{label}: responder health not reported");
1980 } else {
1981 let status = health
1982 .get("status")
1983 .and_then(Value::as_str)
1984 .unwrap_or("unknown");
1985 let reason = health
1986 .get("reason")
1987 .and_then(Value::as_str)
1988 .map(|r| format!(" — {r}"))
1989 .unwrap_or_default();
1990 let last_success = health
1991 .get("last_success_at")
1992 .and_then(Value::as_str)
1993 .map(|t| format!(" (last_success: {t})"))
1994 .unwrap_or_default();
1995 println!("{label}: {status}{reason}{last_success}");
1996 }
1997 Ok(())
1998}
1999
2000fn cmd_status_peer(peer: &str, as_json: bool) -> Result<()> {
2001 let (_label, relay_url, slot_id, slot_token) = relay_slot_for(Some(peer))?;
2002 let client = crate::relay_client::RelayClient::new(&relay_url);
2003
2004 let started = std::time::Instant::now();
2005 let transport_ok = client.healthz().unwrap_or(false);
2006 let latency_ms = started.elapsed().as_millis() as u64;
2007
2008 let (event_count, last_pull_at_unix) = client.slot_state(&slot_id, &slot_token)?;
2009 let now = std::time::SystemTime::now()
2010 .duration_since(std::time::UNIX_EPOCH)
2011 .map(|d| d.as_secs())
2012 .unwrap_or(0);
2013 let attention = match last_pull_at_unix {
2014 Some(last) if now.saturating_sub(last) <= 300 => json!({
2015 "status": "ok",
2016 "last_pull_at_unix": last,
2017 "age_seconds": now.saturating_sub(last),
2018 "event_count": event_count,
2019 }),
2020 Some(last) => json!({
2021 "status": "stale",
2022 "last_pull_at_unix": last,
2023 "age_seconds": now.saturating_sub(last),
2024 "event_count": event_count,
2025 }),
2026 None => json!({
2027 "status": "never_pulled",
2028 "last_pull_at_unix": Value::Null,
2029 "event_count": event_count,
2030 }),
2031 };
2032
2033 let responder_health = client.responder_health_get(&slot_id, &slot_token)?;
2034 let responder = if responder_health.is_null() {
2035 json!({"status": "not_reported", "record": Value::Null})
2036 } else {
2037 json!({
2038 "status": responder_health
2039 .get("status")
2040 .and_then(Value::as_str)
2041 .unwrap_or("unknown"),
2042 "record": responder_health,
2043 })
2044 };
2045
2046 let report = json!({
2047 "peer": peer,
2048 "transport": {
2049 "status": if transport_ok { "ok" } else { "error" },
2050 "relay_url": relay_url,
2051 "latency_ms": latency_ms,
2052 },
2053 "attention": attention,
2054 "responder": responder,
2055 });
2056
2057 if as_json {
2058 println!("{}", serde_json::to_string(&report)?);
2059 } else {
2060 let transport_line = if transport_ok {
2061 format!("ok relay reachable ({latency_ms}ms)")
2062 } else {
2063 "error relay unreachable".to_string()
2064 };
2065 println!("transport {transport_line}");
2066 match report["attention"]["status"].as_str().unwrap_or("unknown") {
2067 "ok" => println!(
2068 "attention ok last pull {}s ago",
2069 report["attention"]["age_seconds"].as_u64().unwrap_or(0)
2070 ),
2071 "stale" => println!(
2072 "attention stale last pull {}m ago",
2073 report["attention"]["age_seconds"].as_u64().unwrap_or(0) / 60
2074 ),
2075 "never_pulled" => println!("attention never pulled since relay reset"),
2076 other => println!("attention {other}"),
2077 }
2078 if report["responder"]["status"] == "not_reported" {
2079 println!("auto-responder not reported");
2080 } else {
2081 let record = &report["responder"]["record"];
2082 let status = record
2083 .get("status")
2084 .and_then(Value::as_str)
2085 .unwrap_or("unknown");
2086 let reason = record
2087 .get("reason")
2088 .and_then(Value::as_str)
2089 .map(|r| format!(" — {r}"))
2090 .unwrap_or_default();
2091 println!("auto-responder {status}{reason}");
2092 }
2093 }
2094 Ok(())
2095}
2096
2097fn current_cwd_display() -> String {
2105 let cwd = match std::env::current_dir() {
2106 Ok(c) => c,
2107 Err(_) => return String::from("?"),
2108 };
2109 if let Some(home) = dirs::home_dir()
2110 && let Ok(rel) = cwd.strip_prefix(&home)
2111 {
2112 let rel_str = rel.to_string_lossy();
2114 if rel_str.is_empty() {
2115 return String::from("~");
2116 }
2117 return format!("~/{}", rel_str);
2118 }
2119 cwd.to_string_lossy().into_owned()
2120}
2121
2122fn cmd_whoami(as_json: bool, short: bool, colored: bool) -> Result<()> {
2123 if !config::is_initialized()? {
2124 bail!("not initialized — run `wire init <handle>` first");
2125 }
2126 let card = config::read_agent_card()?;
2127 let did = card
2128 .get("did")
2129 .and_then(Value::as_str)
2130 .unwrap_or("")
2131 .to_string();
2132 let handle = card
2133 .get("handle")
2134 .and_then(Value::as_str)
2135 .map(str::to_string)
2136 .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
2137 let overrides = config::read_display_overrides().unwrap_or_default();
2140 let character = crate::character::Character::from_did_with_override(
2141 &did,
2142 overrides.nickname.as_deref(),
2143 overrides.emoji.as_deref(),
2144 );
2145
2146 let cwd_display = current_cwd_display();
2152
2153 if short {
2156 println!("{} · {}", character.short(), cwd_display);
2157 return Ok(());
2158 }
2159 if colored {
2160 println!("{} \x1b[2m·\x1b[0m {}", character.colored(), cwd_display);
2161 return Ok(());
2162 }
2163
2164 let pk_b64 = card
2165 .get("verify_keys")
2166 .and_then(Value::as_object)
2167 .and_then(|m| m.values().next())
2168 .and_then(|v| v.get("key"))
2169 .and_then(Value::as_str)
2170 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
2171 let pk_bytes = crate::signing::b64decode(pk_b64)?;
2172 let fp = fingerprint(&pk_bytes);
2173 let key_id = make_key_id(&handle, &pk_bytes);
2174 let capabilities = card
2175 .get("capabilities")
2176 .cloned()
2177 .unwrap_or_else(|| json!(["wire/v3.1"]));
2178
2179 if as_json {
2180 let has_override = overrides.nickname.is_some() || overrides.emoji.is_some();
2181 println!(
2182 "{}",
2183 serde_json::to_string(&json!({
2184 "did": did,
2185 "handle": handle,
2186 "fingerprint": fp,
2187 "key_id": key_id,
2188 "public_key_b64": pk_b64,
2189 "capabilities": capabilities,
2190 "config_dir": config::config_dir()?.to_string_lossy(),
2191 "character": character,
2192 "character_override": has_override,
2193 }))?
2194 );
2195 } else {
2196 println!("{}", character.colored());
2197 println!("{did} (ed25519:{key_id})");
2198 println!("fingerprint: {fp}");
2199 println!("capabilities: {capabilities}");
2200 }
2201 Ok(())
2202}
2203
2204fn cmd_identity(cmd: IdentityCommand) -> Result<()> {
2207 match cmd {
2208 IdentityCommand::Rename {
2209 name,
2210 emoji,
2211 clear,
2212 random,
2213 json,
2214 } => cmd_identity_rename(name, emoji, clear || random, random, json),
2215 IdentityCommand::Show { json } => cmd_whoami(json, !json, false),
2216 IdentityCommand::List { json } => cmd_session_list(json),
2217 IdentityCommand::Publish {
2218 nick,
2219 relay,
2220 public_url,
2221 hidden,
2222 json,
2223 } => cmd_claim(&nick, relay.as_deref(), public_url.as_deref(), hidden, json),
2224 IdentityCommand::Destroy { name, force, json } => cmd_session_destroy(&name, force, json),
2225 IdentityCommand::Create {
2226 name,
2227 anonymous,
2228 local: _,
2229 json,
2230 } => cmd_identity_create(name.as_deref(), anonymous, json),
2231 IdentityCommand::Persist {
2232 name,
2233 as_name,
2234 json,
2235 } => cmd_identity_persist(&name, as_name.as_deref(), json),
2236 IdentityCommand::Demote { name, json } => cmd_identity_demote(&name, json),
2237 }
2238}
2239
2240fn cmd_identity_create(name: Option<&str>, anonymous: bool, as_json: bool) -> Result<()> {
2245 if anonymous {
2246 let rand_suffix = format!("{:08x}", rand::random::<u32>());
2248 let anon_name = name
2249 .map(crate::session::sanitize_name)
2250 .unwrap_or_else(|| format!("anon-{rand_suffix}"));
2251 let anon_root = std::env::temp_dir().join(format!("wire-anon-{rand_suffix}"));
2252 std::fs::create_dir_all(&anon_root)
2253 .with_context(|| format!("creating anon root {anon_root:?}"))?;
2254 let session_home = anon_root.join("sessions").join(&anon_name);
2256 std::fs::create_dir_all(&session_home)?;
2257 let status = run_wire_with_home(&session_home, &["init", &anon_name])?;
2258 if !status.success() {
2259 bail!("anonymous identity init failed: {status}");
2260 }
2261 let marker = anon_root.join("anon-marker.json");
2264 std::fs::write(
2265 &marker,
2266 serde_json::to_vec_pretty(&serde_json::json!({
2267 "name": anon_name,
2268 "session_home": session_home.to_string_lossy(),
2269 "created_at": time::OffsetDateTime::now_utc()
2270 .format(&time::format_description::well_known::Rfc3339)
2271 .unwrap_or_default(),
2272 "kind": "anonymous",
2273 }))?,
2274 )?;
2275 let card = serde_json::from_slice::<Value>(&std::fs::read(
2276 session_home
2277 .join("config")
2278 .join("wire")
2279 .join("agent-card.json"),
2280 )?)?;
2281 let did = card
2282 .get("did")
2283 .and_then(Value::as_str)
2284 .unwrap_or("")
2285 .to_string();
2286 if as_json {
2287 println!(
2288 "{}",
2289 serde_json::to_string(&json!({
2290 "kind": "anonymous",
2291 "name": anon_name,
2292 "did": did,
2293 "session_home": session_home.to_string_lossy(),
2294 "anon_root": anon_root.to_string_lossy(),
2295 }))?
2296 );
2297 } else {
2298 println!("created anonymous identity `{anon_name}` ({did})");
2299 println!(
2300 " session_home: {} (dies on reboot — /tmp)",
2301 session_home.display()
2302 );
2303 println!();
2304 println!("activate in this shell:");
2305 println!(" export WIRE_HOME={}", session_home.display());
2306 println!();
2307 println!("promote to persistent later with:");
2308 println!(" wire identity persist {anon_name}");
2309 }
2310 return Ok(());
2311 }
2312 let name_arg = name.map(|s| s.to_string());
2314 cmd_session_new(
2315 name_arg.as_deref(),
2316 "https://wireup.net",
2317 false,
2318 "http://127.0.0.1:8771",
2319 false,
2320 None,
2321 false,
2322 None,
2323 true, true, as_json,
2326 )
2327}
2328
2329fn cmd_identity_persist(name: &str, as_name: Option<&str>, as_json: bool) -> Result<()> {
2332 let temp = std::env::temp_dir();
2334 let mut found: Option<(std::path::PathBuf, std::path::PathBuf)> = None;
2335 for entry in std::fs::read_dir(&temp)?.flatten() {
2336 let path = entry.path();
2337 if !path
2338 .file_name()
2339 .and_then(|s| s.to_str())
2340 .map(|s| s.starts_with("wire-anon-"))
2341 .unwrap_or(false)
2342 {
2343 continue;
2344 }
2345 let marker = path.join("anon-marker.json");
2346 if let Ok(bytes) = std::fs::read(&marker)
2347 && let Ok(json) = serde_json::from_slice::<Value>(&bytes)
2348 && json.get("name").and_then(Value::as_str) == Some(name)
2349 {
2350 let session_home = json
2351 .get("session_home")
2352 .and_then(Value::as_str)
2353 .map(std::path::PathBuf::from)
2354 .ok_or_else(|| anyhow!("anon-marker {marker:?} missing session_home"))?;
2355 found = Some((path, session_home));
2356 break;
2357 }
2358 }
2359 let (anon_root, anon_session_home) = found.ok_or_else(|| {
2360 anyhow!(
2361 "no anonymous identity named `{name}` found in /tmp/wire-anon-* — \
2362 run `wire identity list` to see available identities"
2363 )
2364 })?;
2365
2366 let new_name = as_name.unwrap_or(name);
2367 let new_session_home = crate::session::session_dir(new_name)?;
2368 if new_session_home.exists() {
2369 bail!(
2370 "target session `{new_name}` already exists at {new_session_home:?} — \
2371 pick a different name with --as <new-name>"
2372 );
2373 }
2374
2375 if let Some(parent) = new_session_home.parent() {
2377 std::fs::create_dir_all(parent)?;
2378 }
2379 std::fs::rename(&anon_session_home, &new_session_home)
2380 .with_context(|| format!("rename {anon_session_home:?} → {new_session_home:?}"))?;
2381
2382 let _ = std::fs::remove_dir_all(&anon_root);
2384
2385 let cwd = std::env::current_dir().unwrap_or_else(|_| new_session_home.clone());
2388 let cwd_key = cwd.to_string_lossy().into_owned();
2389 let new_name_for_reg = new_name.to_string();
2390 if let Err(e) = crate::session::update_registry(|reg| {
2391 reg.by_cwd.insert(cwd_key, new_name_for_reg);
2392 Ok(())
2393 }) {
2394 eprintln!("wire identity persist: failed to update registry: {e:#}");
2395 }
2396
2397 if as_json {
2398 println!(
2399 "{}",
2400 serde_json::to_string(&json!({
2401 "kind": "persisted",
2402 "from_name": name,
2403 "to_name": new_name,
2404 "session_home": new_session_home.to_string_lossy(),
2405 }))?
2406 );
2407 } else {
2408 println!("persisted anonymous identity `{name}` → local session `{new_name}`");
2409 println!(
2410 " session_home: {} (survives reboot)",
2411 new_session_home.display()
2412 );
2413 println!(" registered cwd: {}", cwd.display());
2414 }
2415 Ok(())
2416}
2417
2418fn cmd_identity_demote(name: &str, as_json: bool) -> Result<()> {
2424 let sessions = crate::session::list_sessions()?;
2425 let session = sessions
2426 .iter()
2427 .find(|s| s.name == name)
2428 .ok_or_else(|| anyhow!("no session named `{name}` (run `wire identity list`)"))?;
2429 let relay_state_path = session
2430 .home_dir
2431 .join("config")
2432 .join("wire")
2433 .join("relay.json");
2434 if !relay_state_path.exists() {
2435 bail!("session `{name}` has no relay state — already demoted?");
2436 }
2437 let mut state: Value = serde_json::from_slice(&std::fs::read(&relay_state_path)?)?;
2438 let self_obj = state.get("self").cloned().unwrap_or(Value::Null);
2439 let had_fed = self_obj
2440 .get("relay_url")
2441 .and_then(Value::as_str)
2442 .map(|u| {
2443 u.starts_with("https://") || (u.starts_with("http://") && !u.contains("127.0.0.1"))
2444 })
2445 .unwrap_or(false);
2446 if !had_fed {
2447 if as_json {
2448 println!(
2449 "{}",
2450 serde_json::to_string(
2451 &json!({"name": name, "status": "no-op", "reason": "no federation slot"})
2452 )?
2453 );
2454 } else {
2455 println!("session `{name}` has no federation slot — nothing to demote");
2456 }
2457 return Ok(());
2458 }
2459 if let Some(self_mut) = state
2462 .as_object_mut()
2463 .and_then(|m| m.get_mut("self"))
2464 .and_then(|s| s.as_object_mut())
2465 {
2466 self_mut.remove("relay_url");
2467 self_mut.remove("slot_id");
2468 self_mut.remove("slot_token");
2469 if let Some(eps) = self_mut.get_mut("endpoints").and_then(|e| e.as_array_mut()) {
2470 eps.retain(|ep| ep.get("scope").and_then(Value::as_str) != Some("federation"));
2471 }
2472 }
2473 std::fs::write(&relay_state_path, serde_json::to_vec_pretty(&state)?)?;
2474
2475 if as_json {
2476 println!(
2477 "{}",
2478 serde_json::to_string(
2479 &json!({"name": name, "status": "demoted", "from": "federation", "to": "local"})
2480 )?
2481 );
2482 } else {
2483 println!("demoted `{name}` from federation → local");
2484 println!(" relay slot binding removed; keypair + agent-card retained");
2485 println!(" re-publish with `wire identity publish <nick>`");
2486 }
2487 Ok(())
2488}
2489
2490fn cmd_identity_rename(
2491 name: Option<String>,
2492 emoji: Option<String>,
2493 clear: bool,
2494 random_announce: bool,
2495 as_json: bool,
2496) -> Result<()> {
2497 if !config::is_initialized()? {
2498 bail!("not initialized — run `wire init <handle>` first");
2499 }
2500
2501 let card = config::read_agent_card()?;
2503 let did = card
2504 .get("did")
2505 .and_then(Value::as_str)
2506 .unwrap_or("")
2507 .to_string();
2508
2509 let new_overrides = if clear {
2510 config::DisplayOverrides::default()
2511 } else {
2512 let mut existing = config::read_display_overrides().unwrap_or_default();
2514 if let Some(n) = name {
2515 let cleaned = crate::character::sanitize_display_text(&n);
2520 if cleaned.is_empty() {
2521 bail!(
2522 "nickname `{n:?}` is empty after stripping control characters — pick a name with printable codepoints (max {} chars).",
2523 crate::character::MAX_DISPLAY_CHARS
2524 );
2525 }
2526 if cleaned != n {
2527 eprintln!(
2528 "wire identity rename: stripped control characters from nickname → `{cleaned}`"
2529 );
2530 }
2531 existing.nickname = Some(cleaned);
2532 }
2533 if let Some(e) = emoji {
2534 let cleaned = crate::character::sanitize_display_text(&e);
2535 if cleaned.is_empty() {
2536 bail!(
2537 "emoji `{e:?}` is empty after stripping control characters — pick a printable emoji glyph."
2538 );
2539 }
2540 if cleaned != e {
2541 eprintln!(
2542 "wire identity rename: stripped control characters from emoji → `{cleaned}`"
2543 );
2544 }
2545 existing.emoji = Some(cleaned);
2546 }
2547 existing
2548 };
2549
2550 let no_fields_provided = new_overrides.nickname.is_none()
2553 && new_overrides.emoji.is_none()
2554 && !clear
2555 && !random_announce;
2556 if no_fields_provided {
2557 bail!("nothing to do — pass --name, --emoji, --clear, or --random");
2558 }
2559
2560 config::write_display_overrides(&new_overrides)?;
2561
2562 let signed_card = {
2574 let mut card = config::read_agent_card()?;
2575 if let Some(card_obj) = card.as_object_mut() {
2576 card_obj.remove("signature");
2579 if new_overrides.nickname.is_none() && new_overrides.emoji.is_none() {
2580 card_obj.remove("display");
2581 } else {
2582 let mut display = serde_json::Map::new();
2583 if let Some(n) = &new_overrides.nickname {
2584 display.insert("nickname".into(), Value::String(n.clone()));
2585 }
2586 if let Some(e) = &new_overrides.emoji {
2587 display.insert("emoji".into(), Value::String(e.clone()));
2588 }
2589 card_obj.insert("display".into(), Value::Object(display));
2590 }
2591 }
2592 let sk_seed = config::read_private_key()?;
2593 let signed = crate::agent_card::sign_agent_card(&card, &sk_seed);
2594 config::write_agent_card(&signed)?;
2595 signed
2596 };
2597
2598 if let Ok(state) = config::read_relay_state() {
2602 let self_obj = state.get("self").cloned().unwrap_or(Value::Null);
2603 let fed_url = self_obj.get("relay_url").and_then(Value::as_str);
2604 let fed_slot_id = self_obj.get("slot_id").and_then(Value::as_str);
2605 let fed_slot_token = self_obj.get("slot_token").and_then(Value::as_str);
2606 if let (Some(url), Some(slot_id), Some(slot_token)) = (fed_url, fed_slot_id, fed_slot_token)
2607 {
2608 let is_publishable = url.starts_with("https://")
2611 || (url.starts_with("http://")
2612 && !url.contains("127.0.0.1")
2613 && !url.contains("localhost"));
2614 if is_publishable {
2615 let nick_for_claim = signed_card
2616 .get("handle")
2617 .and_then(Value::as_str)
2618 .map(str::to_string);
2619 if let Some(nick) = nick_for_claim {
2620 let client = crate::relay_client::RelayClient::new(url);
2621 match client.handle_claim_v2(
2622 &nick,
2623 slot_id,
2624 slot_token,
2625 None,
2626 &signed_card,
2627 None,
2628 ) {
2629 Ok(_) => {
2630 eprintln!("wire identity rename: re-published updated card to {url}");
2631 }
2632 Err(e) => {
2633 eprintln!(
2634 "wire identity rename: failed to re-publish to relay {url}: {e:#} — local rename is in effect; federated peers will see the old card until next `wire claim` succeeds"
2635 );
2636 }
2637 }
2638 }
2639 }
2640 }
2641 }
2642
2643 if random_announce {
2644 eprintln!(
2645 "wire identity rename: overrides cleared; falling back to auto-derived character (DID-deterministic, so the character is the same as it was before any rename)."
2646 );
2647 }
2648
2649 let character = crate::character::Character::from_did_with_override(
2650 &did,
2651 new_overrides.nickname.as_deref(),
2652 new_overrides.emoji.as_deref(),
2653 );
2654
2655 if as_json {
2656 println!(
2657 "{}",
2658 serde_json::to_string(&json!({
2659 "did": did,
2660 "character": character,
2661 "overrides": new_overrides,
2662 }))?
2663 );
2664 } else {
2665 println!("renamed → {}", character.colored());
2666 eprintln!(" · palette stays DID-derived (sticky color across renames)");
2667 eprintln!(
2668 " · re-published to your federation relay (if bound); future federation lookups serve \
2669 the updated card. Existing pinned peers have a cached card from pair-time and won't \
2670 see the new name until they re-pair OR fetch your card fresh."
2671 );
2672 }
2673 Ok(())
2674}
2675
2676fn effective_peer_tier(trust: &Value, relay_state: &Value, handle: &str) -> String {
2691 let raw = crate::trust::get_tier(trust, handle);
2692 if raw != "VERIFIED" {
2693 return raw.to_string();
2694 }
2695 let token = relay_state
2696 .get("peers")
2697 .and_then(|p| p.get(handle))
2698 .and_then(|p| p.get("slot_token"))
2699 .and_then(Value::as_str)
2700 .unwrap_or("");
2701 if token.is_empty() {
2702 "PENDING_ACK".to_string()
2703 } else {
2704 raw.to_string()
2705 }
2706}
2707
2708fn cmd_peers(as_json: bool) -> Result<()> {
2709 let trust = config::read_trust()?;
2710 let agents = trust
2711 .get("agents")
2712 .and_then(Value::as_object)
2713 .cloned()
2714 .unwrap_or_default();
2715 let relay_state = config::read_relay_state().unwrap_or_else(|_| json!({"peers": {}}));
2716
2717 let mut self_did: Option<String> = None;
2718 if let Ok(card) = config::read_agent_card() {
2719 self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
2720 }
2721
2722 let mut peers = Vec::new();
2723 for (handle, agent) in agents.iter() {
2724 let did = agent
2725 .get("did")
2726 .and_then(Value::as_str)
2727 .unwrap_or("")
2728 .to_string();
2729 if Some(did.as_str()) == self_did.as_deref() {
2730 continue; }
2732 let tier = effective_peer_tier(&trust, &relay_state, handle);
2733 let capabilities = agent
2734 .get("card")
2735 .and_then(|c| c.get("capabilities"))
2736 .cloned()
2737 .unwrap_or_else(|| json!([]));
2738 let character = if did.is_empty() {
2743 None
2744 } else {
2745 let card_obj = agent.get("card");
2746 Some(match card_obj {
2747 Some(card) => crate::character::Character::from_card(card),
2748 None => crate::character::Character::from_did(&did),
2749 })
2750 };
2751 peers.push(json!({
2752 "handle": handle,
2753 "did": did,
2754 "tier": tier,
2755 "capabilities": capabilities,
2756 "character": character,
2757 }));
2758 }
2759
2760 if as_json {
2761 println!("{}", serde_json::to_string(&peers)?);
2762 } else if peers.is_empty() {
2763 println!("no peers pinned (run `wire join <code>` to pair)");
2764 } else {
2765 for p in &peers {
2771 let char_json = &p["character"];
2772 let (colored_char, plain_len): (String, usize) = match char_json {
2773 serde_json::Value::Null => ("?".to_string(), 1),
2774 v => match serde_json::from_value::<crate::character::Character>(v.clone()) {
2775 Ok(c) => {
2776 let plain = c.short().chars().count() + 1; (c.colored(), plain)
2778 }
2779 Err(_) => ("?".to_string(), 1),
2780 },
2781 };
2782 let pad = 22usize.saturating_sub(plain_len);
2783 println!(
2784 "{}{} {:<20} {:<10} {}",
2785 colored_char,
2786 " ".repeat(pad),
2787 p["handle"].as_str().unwrap_or(""),
2788 p["tier"].as_str().unwrap_or(""),
2789 p["did"].as_str().unwrap_or(""),
2790 );
2791 }
2792 }
2793 Ok(())
2794}
2795
2796fn maybe_warn_peer_attentiveness(peer: &str) {
2806 let state = match config::read_relay_state() {
2807 Ok(s) => s,
2808 Err(_) => return,
2809 };
2810 let p = state.get("peers").and_then(|p| p.get(peer));
2811 let slot_id = match p.and_then(|p| p.get("slot_id")).and_then(Value::as_str) {
2812 Some(s) if !s.is_empty() => s,
2813 _ => return,
2814 };
2815 let slot_token = match p.and_then(|p| p.get("slot_token")).and_then(Value::as_str) {
2816 Some(s) if !s.is_empty() => s,
2817 _ => return,
2818 };
2819 let relay_url = match p.and_then(|p| p.get("relay_url")).and_then(Value::as_str) {
2820 Some(s) if !s.is_empty() => s.to_string(),
2821 _ => match state
2822 .get("self")
2823 .and_then(|s| s.get("relay_url"))
2824 .and_then(Value::as_str)
2825 {
2826 Some(s) if !s.is_empty() => s.to_string(),
2827 _ => return,
2828 },
2829 };
2830 let client = crate::relay_client::RelayClient::new(&relay_url);
2831 let (_count, last_pull) = match client.slot_state(slot_id, slot_token) {
2832 Ok(t) => t,
2833 Err(_) => return,
2834 };
2835 let now = std::time::SystemTime::now()
2836 .duration_since(std::time::UNIX_EPOCH)
2837 .map(|d| d.as_secs())
2838 .unwrap_or(0);
2839 match last_pull {
2840 None => {
2841 eprintln!(
2842 "phyllis: {peer}'s line is silent — relay sees no pulls yet. message will queue, but they may not be listening."
2843 );
2844 }
2845 Some(t) if now.saturating_sub(t) > 300 => {
2846 let mins = now.saturating_sub(t) / 60;
2847 eprintln!(
2848 "phyllis: {peer} hasn't picked up in {mins}m — message will queue, but they may be away."
2849 );
2850 }
2851 _ => {}
2852 }
2853}
2854
2855pub(crate) fn parse_deadline_until(input: &str) -> Result<String> {
2856 let trimmed = input.trim();
2857 if time::OffsetDateTime::parse(trimmed, &time::format_description::well_known::Rfc3339).is_ok()
2858 {
2859 return Ok(trimmed.to_string());
2860 }
2861 let (amount, unit) = trimmed.split_at(trimmed.len().saturating_sub(1));
2862 let n: i64 = amount
2863 .parse()
2864 .with_context(|| format!("deadline must be `30m`, `2h`, `1d`, or RFC3339: {input:?}"))?;
2865 if n <= 0 {
2866 bail!("deadline duration must be positive: {input:?}");
2867 }
2868 let duration = match unit {
2869 "m" => time::Duration::minutes(n),
2870 "h" => time::Duration::hours(n),
2871 "d" => time::Duration::days(n),
2872 _ => bail!("deadline must end in m, h, d, or be RFC3339: {input:?}"),
2873 };
2874 Ok((time::OffsetDateTime::now_utc() + duration)
2875 .format(&time::format_description::well_known::Rfc3339)
2876 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()))
2877}
2878
2879fn cmd_send(
2880 peer: &str,
2881 kind: &str,
2882 body_arg: &str,
2883 deadline: Option<&str>,
2884 as_json: bool,
2885) -> Result<()> {
2886 if !config::is_initialized()? {
2887 bail!("not initialized — run `wire init <handle>` first");
2888 }
2889 let peer_in = crate::agent_card::bare_handle(peer).to_string();
2890 let peer = match resolve_peer_handle(&peer_in) {
2897 Ok(Some(resolved)) if resolved != peer_in => {
2898 eprintln!("wire send: resolved nickname `{peer_in}` → peer `{resolved}`");
2899 resolved
2900 }
2901 Ok(Some(canonical)) => canonical, Ok(None) => peer_in, Err(ResolveError::Ambiguous(candidates)) => bail!(
2904 "nickname `{peer_in}` is ambiguous — matches {} pinned peers: {}. \
2905 Disambiguate by passing the peer handle (one of those listed) instead of the nickname.",
2906 candidates.len(),
2907 candidates.join(", ")
2908 ),
2909 Err(ResolveError::NotFound) => peer_in, };
2911 let peer = peer.as_str();
2912 let sk_seed = config::read_private_key()?;
2913 let card = config::read_agent_card()?;
2914 let did = card.get("did").and_then(Value::as_str).unwrap_or("");
2915 let handle = crate::agent_card::display_handle_from_did(did).to_string();
2916 let pk_b64 = card
2917 .get("verify_keys")
2918 .and_then(Value::as_object)
2919 .and_then(|m| m.values().next())
2920 .and_then(|v| v.get("key"))
2921 .and_then(Value::as_str)
2922 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
2923 let pk_bytes = crate::signing::b64decode(pk_b64)?;
2924
2925 let body_value: Value = if body_arg == "-" {
2930 use std::io::Read;
2931 let mut raw = String::new();
2932 std::io::stdin()
2933 .read_to_string(&mut raw)
2934 .with_context(|| "reading body from stdin")?;
2935 serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
2938 } else if let Some(path) = body_arg.strip_prefix('@') {
2939 let raw =
2940 std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
2941 serde_json::from_str(&raw).unwrap_or(Value::String(raw))
2942 } else {
2943 Value::String(body_arg.to_string())
2944 };
2945
2946 let kind_id = parse_kind(kind)?;
2947
2948 let now = time::OffsetDateTime::now_utc()
2949 .format(&time::format_description::well_known::Rfc3339)
2950 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
2951
2952 let mut event = json!({
2953 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
2954 "timestamp": now,
2955 "from": did,
2956 "to": format!("did:wire:{peer}"),
2957 "type": kind,
2958 "kind": kind_id,
2959 "body": body_value,
2960 });
2961 if let Some(deadline) = deadline {
2962 event["time_sensitive_until"] = json!(parse_deadline_until(deadline)?);
2963 }
2964 let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)?;
2965 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
2966
2967 maybe_warn_peer_attentiveness(peer);
2972
2973 let line = serde_json::to_vec(&signed)?;
2978 let outbox = config::append_outbox_record(peer, &line)?;
2979
2980 if as_json {
2981 println!(
2982 "{}",
2983 serde_json::to_string(&json!({
2984 "event_id": event_id,
2985 "status": "queued",
2986 "peer": peer,
2987 "outbox": outbox.to_string_lossy(),
2988 }))?
2989 );
2990 } else {
2991 println!(
2992 "queued event {event_id} → {peer} (outbox: {})",
2993 outbox.display()
2994 );
2995 }
2996 Ok(())
2997}
2998
2999fn parse_kind(s: &str) -> Result<u32> {
3000 if let Ok(n) = s.parse::<u32>() {
3001 return Ok(n);
3002 }
3003 for (id, name) in crate::signing::kinds() {
3004 if *name == s {
3005 return Ok(*id);
3006 }
3007 }
3008 Ok(1)
3010}
3011
3012fn cmd_tail(peer: Option<&str>, as_json: bool, limit: usize) -> Result<()> {
3015 let inbox = config::inbox_dir()?;
3016 if !inbox.exists() {
3017 if !as_json {
3018 eprintln!("no inbox yet — daemon hasn't run, or no events received");
3019 }
3020 return Ok(());
3021 }
3022 let trust = config::read_trust()?;
3023 let mut count = 0usize;
3024
3025 let entries: Vec<_> = std::fs::read_dir(&inbox)?
3026 .filter_map(|e| e.ok())
3027 .map(|e| e.path())
3028 .filter(|p| {
3029 p.extension().map(|x| x == "jsonl").unwrap_or(false)
3030 && match peer {
3031 Some(want) => p.file_stem().and_then(|s| s.to_str()) == Some(want),
3032 None => true,
3033 }
3034 })
3035 .collect();
3036
3037 for path in entries {
3038 let body = std::fs::read_to_string(&path)?;
3039 for line in body.lines() {
3040 let event: Value = match serde_json::from_str(line) {
3041 Ok(v) => v,
3042 Err(_) => continue,
3043 };
3044 let verified = verify_message_v31(&event, &trust).is_ok();
3045 if as_json {
3046 let mut event_with_meta = event.clone();
3047 if let Some(obj) = event_with_meta.as_object_mut() {
3048 obj.insert("verified".into(), json!(verified));
3049 }
3050 println!("{}", serde_json::to_string(&event_with_meta)?);
3051 } else {
3052 let ts = event
3053 .get("timestamp")
3054 .and_then(Value::as_str)
3055 .unwrap_or("?");
3056 let from = event.get("from").and_then(Value::as_str).unwrap_or("?");
3057 let kind = event.get("kind").and_then(Value::as_u64).unwrap_or(0);
3058 let kind_name = event.get("type").and_then(Value::as_str).unwrap_or("?");
3059 let summary = event
3060 .get("body")
3061 .map(|b| match b {
3062 Value::String(s) => s.clone(),
3063 _ => b.to_string(),
3064 })
3065 .unwrap_or_default();
3066 let mark = if verified { "✓" } else { "✗" };
3067 let deadline = event
3068 .get("time_sensitive_until")
3069 .and_then(Value::as_str)
3070 .map(|d| format!(" deadline: {d}"))
3071 .unwrap_or_default();
3072 println!("[{ts} {from} kind={kind} {kind_name}{deadline}] {summary} | sig {mark}");
3073 }
3074 count += 1;
3075 if limit > 0 && count >= limit {
3076 return Ok(());
3077 }
3078 }
3079 }
3080 Ok(())
3081}
3082
3083fn monitor_is_noise_kind(kind: &str) -> bool {
3089 matches!(kind, "pair_drop" | "pair_drop_ack" | "heartbeat")
3090}
3091
3092fn monitor_render(e: &crate::inbox_watch::InboxEvent, as_json: bool) -> Result<String> {
3096 if as_json {
3097 Ok(serde_json::to_string(e)?)
3098 } else {
3099 let eid_short: String = e.event_id.chars().take(12).collect();
3100 let body = e.body_preview.replace('\n', " ");
3101 let ts: String = e.timestamp.chars().take(19).collect();
3102 Ok(format!("[{ts}] {}/{} ({eid_short}) {body}", e.peer, e.kind))
3103 }
3104}
3105
3106fn cmd_monitor(
3122 peer_filter: Option<&str>,
3123 as_json: bool,
3124 include_handshake: bool,
3125 interval_ms: u64,
3126 replay: usize,
3127) -> Result<()> {
3128 let inbox_dir = config::inbox_dir()?;
3129 if !inbox_dir.exists() && !as_json {
3130 eprintln!("wire monitor: inbox dir {inbox_dir:?} missing — has the daemon ever run?");
3131 }
3132 if replay > 0 && inbox_dir.exists() {
3138 let mut all: Vec<crate::inbox_watch::InboxEvent> = Vec::new();
3139 for entry in std::fs::read_dir(&inbox_dir)?.flatten() {
3140 let path = entry.path();
3141 if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
3142 continue;
3143 }
3144 let peer = match path.file_stem().and_then(|s| s.to_str()) {
3145 Some(s) => s.to_string(),
3146 None => continue,
3147 };
3148 if let Some(filter) = peer_filter
3149 && peer != filter
3150 {
3151 continue;
3152 }
3153 let body = std::fs::read_to_string(&path).unwrap_or_default();
3154 for line in body.lines() {
3155 let line = line.trim();
3156 if line.is_empty() {
3157 continue;
3158 }
3159 let signed: Value = match serde_json::from_str(line) {
3160 Ok(v) => v,
3161 Err(_) => continue,
3162 };
3163 let ev = crate::inbox_watch::InboxEvent::from_signed(
3164 &peer, signed, true,
3165 );
3166 if !include_handshake && monitor_is_noise_kind(&ev.kind) {
3167 continue;
3168 }
3169 all.push(ev);
3170 }
3171 }
3172 all.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
3175 let start = all.len().saturating_sub(replay);
3176 for ev in &all[start..] {
3177 println!("{}", monitor_render(ev, as_json)?);
3178 }
3179 use std::io::Write;
3180 std::io::stdout().flush().ok();
3181 }
3182
3183 let mut w = crate::inbox_watch::InboxWatcher::from_head()?;
3186 let sleep_dur = std::time::Duration::from_millis(interval_ms.max(50));
3187
3188 loop {
3189 let events = w.poll()?;
3190 let mut wrote = false;
3191 for ev in events {
3192 if let Some(filter) = peer_filter
3193 && ev.peer != filter
3194 {
3195 continue;
3196 }
3197 if !include_handshake && monitor_is_noise_kind(&ev.kind) {
3198 continue;
3199 }
3200 println!("{}", monitor_render(&ev, as_json)?);
3201 wrote = true;
3202 }
3203 if wrote {
3204 use std::io::Write;
3205 std::io::stdout().flush().ok();
3206 }
3207 std::thread::sleep(sleep_dur);
3208 }
3209}
3210
3211#[cfg(test)]
3212mod tier_tests {
3213 use super::*;
3214 use serde_json::json;
3215
3216 fn trust_with(handle: &str, tier: &str) -> Value {
3217 json!({
3218 "version": 1,
3219 "agents": {
3220 handle: {
3221 "tier": tier,
3222 "did": format!("did:wire:{handle}"),
3223 "card": {"capabilities": ["wire/v3.1"]}
3224 }
3225 }
3226 })
3227 }
3228
3229 #[test]
3230 fn pending_ack_when_verified_but_no_slot_token() {
3231 let trust = trust_with("willard", "VERIFIED");
3235 let relay_state = json!({
3236 "peers": {
3237 "willard": {
3238 "relay_url": "https://relay",
3239 "slot_id": "abc",
3240 "slot_token": "",
3241 }
3242 }
3243 });
3244 assert_eq!(
3245 effective_peer_tier(&trust, &relay_state, "willard"),
3246 "PENDING_ACK"
3247 );
3248 }
3249
3250 #[test]
3251 fn verified_when_slot_token_present() {
3252 let trust = trust_with("willard", "VERIFIED");
3253 let relay_state = json!({
3254 "peers": {
3255 "willard": {
3256 "relay_url": "https://relay",
3257 "slot_id": "abc",
3258 "slot_token": "tok123",
3259 }
3260 }
3261 });
3262 assert_eq!(
3263 effective_peer_tier(&trust, &relay_state, "willard"),
3264 "VERIFIED"
3265 );
3266 }
3267
3268 #[test]
3269 fn raw_tier_passes_through_for_non_verified() {
3270 let trust = trust_with("willard", "UNTRUSTED");
3273 let relay_state = json!({
3274 "peers": {"willard": {"slot_token": ""}}
3275 });
3276 assert_eq!(
3277 effective_peer_tier(&trust, &relay_state, "willard"),
3278 "UNTRUSTED"
3279 );
3280 }
3281
3282 #[test]
3283 fn pending_ack_when_relay_state_missing_peer() {
3284 let trust = trust_with("willard", "VERIFIED");
3288 let relay_state = json!({"peers": {}});
3289 assert_eq!(
3290 effective_peer_tier(&trust, &relay_state, "willard"),
3291 "PENDING_ACK"
3292 );
3293 }
3294}
3295
3296#[cfg(test)]
3297mod monitor_tests {
3298 use super::*;
3299 use crate::inbox_watch::InboxEvent;
3300 use serde_json::Value;
3301
3302 fn ev(peer: &str, kind: &str, body: &str) -> InboxEvent {
3303 InboxEvent {
3304 peer: peer.to_string(),
3305 event_id: "abcd1234567890ef".to_string(),
3306 kind: kind.to_string(),
3307 body_preview: body.to_string(),
3308 verified: true,
3309 timestamp: "2026-05-15T23:14:07.123456Z".to_string(),
3310 raw: Value::Null,
3311 }
3312 }
3313
3314 #[test]
3315 fn monitor_filter_drops_handshake_kinds_by_default() {
3316 assert!(monitor_is_noise_kind("pair_drop"));
3321 assert!(monitor_is_noise_kind("pair_drop_ack"));
3322 assert!(monitor_is_noise_kind("heartbeat"));
3323
3324 assert!(!monitor_is_noise_kind("claim"));
3326 assert!(!monitor_is_noise_kind("decision"));
3327 assert!(!monitor_is_noise_kind("ack"));
3328 assert!(!monitor_is_noise_kind("request"));
3329 assert!(!monitor_is_noise_kind("note"));
3330 assert!(!monitor_is_noise_kind("future_kind_we_dont_know"));
3334 }
3335
3336 #[test]
3337 fn monitor_render_plain_is_one_short_line() {
3338 let e = ev("willard", "claim", "real v8 train shipped 1350 steps");
3339 let line = monitor_render(&e, false).unwrap();
3340 assert!(!line.contains('\n'), "render must be one line: {line}");
3342 assert!(line.contains("willard"));
3344 assert!(line.contains("claim"));
3345 assert!(line.contains("real v8 train"));
3346 assert!(line.contains("abcd12345678"));
3348 assert!(
3349 !line.contains("abcd1234567890ef"),
3350 "should truncate full id"
3351 );
3352 assert!(line.contains("2026-05-15T23:14:07"));
3354 }
3355
3356 #[test]
3357 fn monitor_render_strips_newlines_from_body() {
3358 let e = ev("spark", "claim", "line one\nline two\nline three");
3363 let line = monitor_render(&e, false).unwrap();
3364 assert!(!line.contains('\n'), "newlines must be stripped: {line}");
3365 assert!(line.contains("line one line two line three"));
3366 }
3367
3368 #[test]
3369 fn monitor_render_json_is_valid_jsonl() {
3370 let e = ev("spark", "claim", "hi");
3371 let line = monitor_render(&e, true).unwrap();
3372 assert!(!line.contains('\n'));
3373 let parsed: Value = serde_json::from_str(&line).expect("valid JSONL");
3374 assert_eq!(parsed["peer"], "spark");
3375 assert_eq!(parsed["kind"], "claim");
3376 assert_eq!(parsed["body_preview"], "hi");
3377 }
3378
3379 #[test]
3380 fn monitor_does_not_drop_on_verified_null() {
3381 let mut e = ev("spark", "claim", "from disk with verified=null");
3392 e.verified = false; let line = monitor_render(&e, false).unwrap();
3394 assert!(line.contains("from disk with verified=null"));
3395 assert!(!monitor_is_noise_kind("claim"));
3397 }
3398}
3399
3400fn cmd_verify(path: &str, as_json: bool) -> Result<()> {
3403 let body = if path == "-" {
3404 let mut buf = String::new();
3405 use std::io::Read;
3406 std::io::stdin().read_to_string(&mut buf)?;
3407 buf
3408 } else {
3409 std::fs::read_to_string(path).with_context(|| format!("reading {path}"))?
3410 };
3411 let event: Value = serde_json::from_str(&body)?;
3412 let trust = config::read_trust()?;
3413 match verify_message_v31(&event, &trust) {
3414 Ok(()) => {
3415 if as_json {
3416 println!("{}", serde_json::to_string(&json!({"verified": true}))?);
3417 } else {
3418 println!("verified ✓");
3419 }
3420 Ok(())
3421 }
3422 Err(e) => {
3423 let reason = e.to_string();
3424 if as_json {
3425 println!(
3426 "{}",
3427 serde_json::to_string(&json!({"verified": false, "reason": reason}))?
3428 );
3429 } else {
3430 eprintln!("FAILED: {reason}");
3431 }
3432 std::process::exit(1);
3433 }
3434 }
3435}
3436
3437fn cmd_mcp() -> Result<()> {
3440 crate::mcp::run()
3441}
3442
3443fn cmd_relay_server(bind: &str, local_only: bool, uds: Option<&std::path::Path>) -> Result<()> {
3444 if let Some(socket_path) = uds {
3449 let base = if let Ok(home) = std::env::var("WIRE_HOME") {
3450 std::path::PathBuf::from(home)
3451 .join("state")
3452 .join("wire-relay")
3453 .join("uds")
3454 } else {
3455 dirs::state_dir()
3456 .or_else(dirs::data_local_dir)
3457 .ok_or_else(|| anyhow::anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?
3458 .join("wire-relay")
3459 .join("uds")
3460 };
3461 let runtime = tokio::runtime::Builder::new_multi_thread()
3462 .enable_all()
3463 .build()?;
3464 return runtime.block_on(crate::relay_server::serve_uds(
3465 socket_path.to_path_buf(),
3466 base,
3467 ));
3468 }
3469 if local_only {
3473 validate_loopback_bind(bind)?;
3474 }
3475 let base = if let Ok(home) = std::env::var("WIRE_HOME") {
3481 std::path::PathBuf::from(home)
3482 .join("state")
3483 .join("wire-relay")
3484 } else {
3485 dirs::state_dir()
3486 .or_else(dirs::data_local_dir)
3487 .ok_or_else(|| anyhow::anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?
3488 .join("wire-relay")
3489 };
3490 let state_dir = if local_only { base.join("local") } else { base };
3491 let runtime = tokio::runtime::Builder::new_multi_thread()
3492 .enable_all()
3493 .build()?;
3494 runtime.block_on(crate::relay_server::serve_with_mode(
3495 bind,
3496 state_dir,
3497 crate::relay_server::ServerMode { local_only },
3498 ))
3499}
3500
3501fn validate_loopback_bind(bind: &str) -> Result<()> {
3519 let host = if let Some(stripped) = bind.strip_prefix('[') {
3521 let close = stripped
3522 .find(']')
3523 .ok_or_else(|| anyhow::anyhow!("malformed IPv6 bind {bind:?}"))?;
3524 stripped[..close].to_string()
3525 } else {
3526 bind.rsplit_once(':')
3527 .map(|(h, _)| h.to_string())
3528 .unwrap_or_else(|| bind.to_string())
3529 };
3530 use std::net::{IpAddr, ToSocketAddrs};
3531 let probe = format!("{host}:0");
3532 let resolved: Vec<_> = probe
3533 .to_socket_addrs()
3534 .with_context(|| format!("resolving bind host {host:?}"))?
3535 .collect();
3536 if resolved.is_empty() {
3537 bail!("--local-only: bind host {host:?} resolved to no addresses");
3538 }
3539 for addr in &resolved {
3540 let ip = addr.ip();
3541 let is_acceptable = match ip {
3542 IpAddr::V4(v4) => {
3543 v4.is_loopback() || v4.is_private() || {
3544 let octets = v4.octets();
3546 octets[0] == 100 && (64..=127).contains(&octets[1])
3547 }
3548 }
3549 IpAddr::V6(v6) => v6.is_loopback(), };
3551 if !is_acceptable {
3552 bail!(
3553 "--local-only refuses non-private bind: {host:?} resolves to {} \
3554 which is not loopback (127/8, ::1), RFC 1918 private \
3555 (10/8, 172.16/12, 192.168/16), or RFC 6598 CGNAT/Tailscale \
3556 (100.64.0.0/10). Remove --local-only to bind publicly.",
3557 ip
3558 );
3559 }
3560 }
3561 Ok(())
3562}
3563
3564fn cmd_bind_relay(url: &str, migrate_pinned: bool, as_json: bool) -> Result<()> {
3567 if !config::is_initialized()? {
3568 bail!("not initialized — run `wire init <handle>` first");
3569 }
3570 let card = config::read_agent_card()?;
3571 let did = card.get("did").and_then(Value::as_str).unwrap_or("");
3572 let handle = crate::agent_card::display_handle_from_did(did).to_string();
3573
3574 let existing = config::read_relay_state().unwrap_or_else(|_| json!({}));
3581 let pinned: Vec<String> = existing
3582 .get("peers")
3583 .and_then(|p| p.as_object())
3584 .map(|o| o.keys().cloned().collect())
3585 .unwrap_or_default();
3586 if !pinned.is_empty() && !migrate_pinned {
3587 let list = pinned.join(", ");
3588 bail!(
3589 "bind-relay would silently black-hole {n} pinned peer(s): {list}. \
3590 They are pinned to your CURRENT slot; without coordination they will keep \
3591 pushing to a slot you no longer read.\n\n\
3592 SAFE PATHS:\n\
3593 • `wire rotate-slot` — rotates slot on the SAME relay and emits a \
3594 wire_close event to every pinned peer so their daemons drop the stale \
3595 coords cleanly. This is the supported migration path.\n\
3596 • `wire bind-relay {url} --migrate-pinned` — acknowledges that pinned \
3597 peers will need to re-pin manually (you must notify them out-of-band, \
3598 via a fresh `wire add` from each peer or a re-shared invite). Use this \
3599 only when the current slot is unreachable so rotate-slot can't ack.\n\n\
3600 Issue #7 (silent black-hole on relay change) caught this — proceed only \
3601 if you understand the consequences.",
3602 n = pinned.len(),
3603 );
3604 }
3605
3606 let normalized = url.trim_end_matches('/');
3607 let client = crate::relay_client::RelayClient::new(normalized);
3608 client.check_healthz()?;
3609 let alloc = client.allocate_slot(Some(&handle))?;
3610 let mut state = existing;
3611 if !pinned.is_empty() {
3612 eprintln!(
3616 "wire bind-relay: migrating with {n} pinned peer(s) — they will black-hole \
3617 until they re-pin: {peers}",
3618 n = pinned.len(),
3619 peers = pinned.join(", "),
3620 );
3621 }
3622 state["self"] = json!({
3623 "relay_url": url,
3624 "slot_id": alloc.slot_id,
3625 "slot_token": alloc.slot_token,
3626 });
3627 config::write_relay_state(&state)?;
3628
3629 if as_json {
3630 println!(
3631 "{}",
3632 serde_json::to_string(&json!({
3633 "relay_url": url,
3634 "slot_id": alloc.slot_id,
3635 "slot_token_present": true,
3636 }))?
3637 );
3638 } else {
3639 println!("bound to relay {url}");
3640 println!("slot_id: {}", alloc.slot_id);
3641 println!(
3642 "(slot_token written to {} mode 0600)",
3643 config::relay_state_path()?.display()
3644 );
3645 }
3646 Ok(())
3647}
3648
3649fn cmd_add_peer_slot(
3652 handle: &str,
3653 url: &str,
3654 slot_id: &str,
3655 slot_token: &str,
3656 as_json: bool,
3657) -> Result<()> {
3658 let mut state = config::read_relay_state()?;
3659 let peers = state["peers"]
3660 .as_object_mut()
3661 .ok_or_else(|| anyhow!("relay state missing 'peers' object"))?;
3662 peers.insert(
3663 handle.to_string(),
3664 json!({
3665 "relay_url": url,
3666 "slot_id": slot_id,
3667 "slot_token": slot_token,
3668 }),
3669 );
3670 config::write_relay_state(&state)?;
3671 if as_json {
3672 println!(
3673 "{}",
3674 serde_json::to_string(&json!({
3675 "handle": handle,
3676 "relay_url": url,
3677 "slot_id": slot_id,
3678 "added": true,
3679 }))?
3680 );
3681 } else {
3682 println!("pinned peer slot for {handle} at {url} ({slot_id})");
3683 }
3684 Ok(())
3685}
3686
3687fn cmd_push(peer_filter: Option<&str>, as_json: bool) -> Result<()> {
3690 let state = config::read_relay_state()?;
3691 let peers = state["peers"].as_object().cloned().unwrap_or_default();
3692 if peers.is_empty() {
3693 bail!(
3694 "no peer slots pinned — run `wire add-peer-slot <handle> <url> <slot_id> <token>` first"
3695 );
3696 }
3697 let outbox_dir = config::outbox_dir()?;
3698 if outbox_dir.exists() {
3703 let pinned: std::collections::HashSet<String> = peers.keys().cloned().collect();
3704 for entry in std::fs::read_dir(&outbox_dir)?.flatten() {
3705 let path = entry.path();
3706 if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
3707 continue;
3708 }
3709 let stem = match path.file_stem().and_then(|s| s.to_str()) {
3710 Some(s) => s.to_string(),
3711 None => continue,
3712 };
3713 if pinned.contains(&stem) {
3714 continue;
3715 }
3716 let bare = crate::agent_card::bare_handle(&stem);
3719 if pinned.contains(bare) {
3720 eprintln!(
3721 "wire push: WARN stale outbox file `{}.jsonl` not enumerated (pinned peer is `{bare}`). \
3722 Merge with: `cat {} >> {}` then delete the FQDN file.",
3723 stem,
3724 path.display(),
3725 outbox_dir.join(format!("{bare}.jsonl")).display(),
3726 );
3727 }
3728 }
3729 }
3730 if !outbox_dir.exists() {
3731 if as_json {
3732 println!(
3733 "{}",
3734 serde_json::to_string(&json!({"pushed": [], "skipped": []}))?
3735 );
3736 } else {
3737 println!("phyllis: nothing to dial out — write a message first with `wire send`");
3738 }
3739 return Ok(());
3740 }
3741
3742 let mut pushed = Vec::new();
3743 let mut skipped = Vec::new();
3744
3745 for (peer_handle, _) in peers.iter() {
3751 if let Some(want) = peer_filter
3752 && peer_handle != want
3753 {
3754 continue;
3755 }
3756 let outbox = outbox_dir.join(format!("{peer_handle}.jsonl"));
3757 if !outbox.exists() {
3758 continue;
3759 }
3760 let ordered_endpoints =
3761 crate::endpoints::peer_endpoints_in_priority_order(&state, peer_handle);
3762 if ordered_endpoints.is_empty() {
3763 for line in std::fs::read_to_string(&outbox).unwrap_or_default().lines() {
3767 let event: Value = match serde_json::from_str(line) {
3768 Ok(v) => v,
3769 Err(_) => continue,
3770 };
3771 let event_id = event
3772 .get("event_id")
3773 .and_then(Value::as_str)
3774 .unwrap_or("")
3775 .to_string();
3776 skipped.push(json!({
3777 "peer": peer_handle,
3778 "event_id": event_id,
3779 "reason": "no reachable endpoint pinned for peer",
3780 }));
3781 }
3782 continue;
3783 }
3784 let body = std::fs::read_to_string(&outbox)?;
3785 for line in body.lines() {
3786 let event: Value = match serde_json::from_str(line) {
3787 Ok(v) => v,
3788 Err(_) => continue,
3789 };
3790 let event_id = event
3791 .get("event_id")
3792 .and_then(Value::as_str)
3793 .unwrap_or("")
3794 .to_string();
3795
3796 let mut delivered = false;
3797 let mut last_err_reason: Option<String> = None;
3798 for endpoint in &ordered_endpoints {
3799 let client = crate::relay_client::RelayClient::new(&endpoint.relay_url);
3800 match client.post_event(&endpoint.slot_id, &endpoint.slot_token, &event) {
3801 Ok(resp) => {
3802 if resp.status == "duplicate" {
3803 skipped.push(json!({
3804 "peer": peer_handle,
3805 "event_id": event_id,
3806 "reason": "duplicate",
3807 "endpoint": endpoint.relay_url,
3808 "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
3809 }));
3810 } else {
3811 pushed.push(json!({
3812 "peer": peer_handle,
3813 "event_id": event_id,
3814 "endpoint": endpoint.relay_url,
3815 "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
3816 }));
3817 }
3818 delivered = true;
3819 break;
3820 }
3821 Err(e) => {
3822 last_err_reason = Some(crate::relay_client::format_transport_error(&e));
3827 }
3828 }
3829 }
3830 if !delivered {
3831 skipped.push(json!({
3832 "peer": peer_handle,
3833 "event_id": event_id,
3834 "reason": last_err_reason.unwrap_or_else(|| "all endpoints failed".to_string()),
3835 }));
3836 }
3837 }
3838 }
3839
3840 if as_json {
3841 println!(
3842 "{}",
3843 serde_json::to_string(&json!({"pushed": pushed, "skipped": skipped}))?
3844 );
3845 } else {
3846 println!(
3847 "pushed {} event(s); skipped {} ({})",
3848 pushed.len(),
3849 skipped.len(),
3850 if skipped.is_empty() {
3851 "none"
3852 } else {
3853 "see --json for detail"
3854 }
3855 );
3856 }
3857 Ok(())
3858}
3859
3860fn cmd_pull(as_json: bool) -> Result<()> {
3863 let state = config::read_relay_state()?;
3864 let self_state = state.get("self").cloned().unwrap_or(Value::Null);
3865 if self_state.is_null() {
3866 bail!("self slot not bound — run `wire bind-relay <url>` first");
3867 }
3868
3869 let endpoints = crate::endpoints::self_endpoints(&state);
3878 if endpoints.is_empty() {
3879 bail!("self.relay_url / slot_id / slot_token missing in relay_state.json");
3880 }
3881
3882 let inbox_dir = config::inbox_dir()?;
3883 config::ensure_dirs()?;
3884
3885 let mut total_seen = 0usize;
3886 let mut all_written: Vec<Value> = Vec::new();
3887 let mut all_rejected: Vec<Value> = Vec::new();
3888 let mut all_blocked = false;
3889 let mut all_advance_cursor_to: Option<String> = None;
3890
3891 for endpoint in &endpoints {
3892 let cursor_key = endpoint_cursor_key(endpoint.scope);
3893 let last_event_id = self_state
3894 .get(&cursor_key)
3895 .and_then(Value::as_str)
3896 .map(str::to_string);
3897 let client = crate::relay_client::RelayClient::new(&endpoint.relay_url);
3898 let events = match client.list_events(
3899 &endpoint.slot_id,
3900 &endpoint.slot_token,
3901 last_event_id.as_deref(),
3902 Some(1000),
3903 ) {
3904 Ok(ev) => ev,
3905 Err(e) => {
3906 eprintln!(
3910 "wire pull: endpoint {} ({:?}) errored: {}; continuing",
3911 endpoint.relay_url,
3912 endpoint.scope,
3913 crate::relay_client::format_transport_error(&e),
3914 );
3915 continue;
3916 }
3917 };
3918 total_seen += events.len();
3919 let result = crate::pull::process_events(&events, last_event_id.clone(), &inbox_dir)?;
3920 all_written.extend(result.written.iter().cloned());
3921 all_rejected.extend(result.rejected.iter().cloned());
3922 if result.blocked {
3923 all_blocked = true;
3924 }
3925 if let Some(eid) = result.advance_cursor_to.clone() {
3928 if endpoint.scope == crate::endpoints::EndpointScope::Federation {
3929 all_advance_cursor_to = Some(eid.clone());
3930 }
3931 let key = cursor_key.clone();
3932 config::update_relay_state(|state| {
3933 if let Some(self_obj) = state.get_mut("self").and_then(Value::as_object_mut) {
3934 self_obj.insert(key, Value::String(eid));
3935 }
3936 Ok(())
3937 })?;
3938 }
3939 }
3940
3941 let result = crate::pull::PullResult {
3946 written: all_written,
3947 rejected: all_rejected,
3948 blocked: all_blocked,
3949 advance_cursor_to: all_advance_cursor_to,
3950 };
3951 let events_len = total_seen;
3952
3953 if as_json {
3957 println!(
3958 "{}",
3959 serde_json::to_string(&json!({
3960 "written": result.written,
3961 "rejected": result.rejected,
3962 "total_seen": events_len,
3963 "cursor_blocked": result.blocked,
3964 "cursor_advanced_to": result.advance_cursor_to,
3965 }))?
3966 );
3967 } else {
3968 let blocking = result
3969 .rejected
3970 .iter()
3971 .filter(|r| r.get("blocks_cursor").and_then(Value::as_bool) == Some(true))
3972 .count();
3973 if blocking > 0 {
3974 println!(
3975 "pulled {} event(s); wrote {}; rejected {} ({} BLOCKING cursor — see `wire pull --json`)",
3976 events_len,
3977 result.written.len(),
3978 result.rejected.len(),
3979 blocking,
3980 );
3981 } else {
3982 println!(
3983 "pulled {} event(s); wrote {}; rejected {}",
3984 events_len,
3985 result.written.len(),
3986 result.rejected.len(),
3987 );
3988 }
3989 }
3990 Ok(())
3991}
3992
3993fn endpoint_cursor_key(scope: crate::endpoints::EndpointScope) -> String {
3998 match scope {
3999 crate::endpoints::EndpointScope::Federation => "last_pulled_event_id".to_string(),
4000 crate::endpoints::EndpointScope::Local => "last_pulled_event_id_local".to_string(),
4001 crate::endpoints::EndpointScope::Lan => "last_pulled_event_id_lan".to_string(),
4002 crate::endpoints::EndpointScope::Uds => "last_pulled_event_id_uds".to_string(),
4003 }
4004}
4005
4006fn cmd_rotate_slot(no_announce: bool, as_json: bool) -> Result<()> {
4009 if !config::is_initialized()? {
4010 bail!("not initialized — run `wire init <handle>` first");
4011 }
4012 let mut state = config::read_relay_state()?;
4013 let self_state = state.get("self").cloned().unwrap_or(Value::Null);
4014 if self_state.is_null() {
4015 bail!("self slot not bound — run `wire bind-relay <url>` first (nothing to rotate)");
4016 }
4017 let url = self_state["relay_url"]
4018 .as_str()
4019 .ok_or_else(|| anyhow!("self.relay_url missing"))?
4020 .to_string();
4021 let old_slot_id = self_state["slot_id"]
4022 .as_str()
4023 .ok_or_else(|| anyhow!("self.slot_id missing"))?
4024 .to_string();
4025 let old_slot_token = self_state["slot_token"]
4026 .as_str()
4027 .ok_or_else(|| anyhow!("self.slot_token missing"))?
4028 .to_string();
4029
4030 let card = config::read_agent_card()?;
4032 let did = card
4033 .get("did")
4034 .and_then(Value::as_str)
4035 .unwrap_or("")
4036 .to_string();
4037 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
4038 let pk_b64 = card
4039 .get("verify_keys")
4040 .and_then(Value::as_object)
4041 .and_then(|m| m.values().next())
4042 .and_then(|v| v.get("key"))
4043 .and_then(Value::as_str)
4044 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?
4045 .to_string();
4046 let pk_bytes = crate::signing::b64decode(&pk_b64)?;
4047 let sk_seed = config::read_private_key()?;
4048
4049 let normalized = url.trim_end_matches('/').to_string();
4051 let client = crate::relay_client::RelayClient::new(&normalized);
4052 client
4053 .check_healthz()
4054 .context("aborting rotation; old slot still valid")?;
4055 let alloc = client.allocate_slot(Some(&handle))?;
4056 let new_slot_id = alloc.slot_id.clone();
4057 let new_slot_token = alloc.slot_token.clone();
4058
4059 let mut announced: Vec<String> = Vec::new();
4066 if !no_announce {
4067 let now = time::OffsetDateTime::now_utc()
4068 .format(&time::format_description::well_known::Rfc3339)
4069 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
4070 let body = json!({
4071 "reason": "operator-initiated slot rotation",
4072 "new_relay_url": url,
4073 "new_slot_id": new_slot_id,
4074 });
4078 let peers = state["peers"].as_object().cloned().unwrap_or_default();
4079 for (peer_handle, _peer_info) in peers.iter() {
4080 let event = json!({
4081 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
4082 "timestamp": now.clone(),
4083 "from": did,
4084 "to": format!("did:wire:{peer_handle}"),
4085 "type": "wire_close",
4086 "kind": 1201,
4087 "body": body.clone(),
4088 });
4089 let signed = match sign_message_v31(&event, &sk_seed, &pk_bytes, &handle) {
4090 Ok(s) => s,
4091 Err(e) => {
4092 eprintln!("warn: could not sign wire_close for {peer_handle}: {e}");
4093 continue;
4094 }
4095 };
4096 let peer_info = match state["peers"].get(peer_handle) {
4101 Some(p) => p.clone(),
4102 None => continue,
4103 };
4104 let peer_url = peer_info["relay_url"].as_str().unwrap_or(&url);
4105 let peer_slot_id = peer_info["slot_id"].as_str().unwrap_or("");
4106 let peer_slot_token = peer_info["slot_token"].as_str().unwrap_or("");
4107 if peer_slot_id.is_empty() || peer_slot_token.is_empty() {
4108 continue;
4109 }
4110 let peer_client = if peer_url == url {
4111 client.clone()
4112 } else {
4113 crate::relay_client::RelayClient::new(peer_url)
4114 };
4115 match peer_client.post_event(peer_slot_id, peer_slot_token, &signed) {
4116 Ok(_) => announced.push(peer_handle.clone()),
4117 Err(e) => eprintln!("warn: announce to {peer_handle} failed: {e}"),
4118 }
4119 }
4120 }
4121
4122 state["self"] = json!({
4124 "relay_url": url,
4125 "slot_id": new_slot_id,
4126 "slot_token": new_slot_token,
4127 });
4128 config::write_relay_state(&state)?;
4129
4130 if as_json {
4131 println!(
4132 "{}",
4133 serde_json::to_string(&json!({
4134 "rotated": true,
4135 "old_slot_id": old_slot_id,
4136 "new_slot_id": new_slot_id,
4137 "relay_url": url,
4138 "announced_to": announced,
4139 }))?
4140 );
4141 } else {
4142 println!("rotated slot on {url}");
4143 println!(
4144 " old slot_id: {old_slot_id} (orphaned — abusive bearer-holders lose their leverage)"
4145 );
4146 println!(" new slot_id: {new_slot_id}");
4147 if !announced.is_empty() {
4148 println!(
4149 " announced wire_close (kind=1201) to: {}",
4150 announced.join(", ")
4151 );
4152 }
4153 println!();
4154 println!("next steps:");
4155 println!(" - peers see the wire_close event in their next `wire pull`");
4156 println!(
4157 " - paired peers must re-issue: tell them to run `wire add-peer-slot {handle} {url} {new_slot_id} <new-token>`"
4158 );
4159 println!(" (or full re-pair via `wire pair-host`/`wire join`)");
4160 println!(" - until they do, you'll receive but they won't be able to reach you");
4161 let _ = old_slot_token;
4163 }
4164 Ok(())
4165}
4166
4167fn cmd_forget_peer(handle: &str, purge: bool, as_json: bool) -> Result<()> {
4170 let mut trust = config::read_trust()?;
4171 let mut removed_from_trust = false;
4172 if let Some(agents) = trust.get_mut("agents").and_then(Value::as_object_mut)
4173 && agents.remove(handle).is_some()
4174 {
4175 removed_from_trust = true;
4176 }
4177 config::write_trust(&trust)?;
4178
4179 let mut state = config::read_relay_state()?;
4180 let mut removed_from_relay = false;
4181 if let Some(peers) = state.get_mut("peers").and_then(Value::as_object_mut)
4182 && peers.remove(handle).is_some()
4183 {
4184 removed_from_relay = true;
4185 }
4186 config::write_relay_state(&state)?;
4187
4188 let mut purged: Vec<String> = Vec::new();
4189 if purge {
4190 for dir in [config::inbox_dir()?, config::outbox_dir()?] {
4191 let path = dir.join(format!("{handle}.jsonl"));
4192 if path.exists() {
4193 std::fs::remove_file(&path).with_context(|| format!("removing {path:?}"))?;
4194 purged.push(path.to_string_lossy().into());
4195 }
4196 }
4197 }
4198
4199 if !removed_from_trust && !removed_from_relay {
4200 if as_json {
4201 println!(
4202 "{}",
4203 serde_json::to_string(&json!({
4204 "removed": false,
4205 "reason": format!("peer {handle:?} not pinned"),
4206 }))?
4207 );
4208 } else {
4209 eprintln!("peer {handle:?} not found in trust or relay state — nothing to forget");
4210 }
4211 return Ok(());
4212 }
4213
4214 if as_json {
4215 println!(
4216 "{}",
4217 serde_json::to_string(&json!({
4218 "handle": handle,
4219 "removed_from_trust": removed_from_trust,
4220 "removed_from_relay_state": removed_from_relay,
4221 "purged_files": purged,
4222 }))?
4223 );
4224 } else {
4225 println!("forgot peer {handle:?}");
4226 if removed_from_trust {
4227 println!(" - removed from trust.json");
4228 }
4229 if removed_from_relay {
4230 println!(" - removed from relay.json");
4231 }
4232 if !purged.is_empty() {
4233 for p in &purged {
4234 println!(" - deleted {p}");
4235 }
4236 } else if !purge {
4237 println!(" (inbox/outbox files preserved; pass --purge to delete them)");
4238 }
4239 }
4240 Ok(())
4241}
4242
4243fn cmd_daemon(interval_secs: u64, once: bool, as_json: bool) -> Result<()> {
4246 if !config::is_initialized()? {
4247 bail!("not initialized — run `wire init <handle>` first");
4248 }
4249 let interval = std::time::Duration::from_secs(interval_secs.max(1));
4250
4251 if !as_json {
4252 if once {
4253 eprintln!("wire daemon: single sync cycle, then exit");
4254 } else {
4255 eprintln!("wire daemon: syncing every {interval_secs}s. SIGINT to stop.");
4256 }
4257 }
4258
4259 if let Err(e) = crate::pending_pair::cleanup_on_startup() {
4263 eprintln!("daemon: pending-pair cleanup_on_startup error: {e:#}");
4264 }
4265
4266 let (wake_tx, wake_rx) = std::sync::mpsc::channel::<()>();
4272 if !once {
4273 crate::daemon_stream::spawn_stream_subscriber(wake_tx);
4274 }
4275
4276 loop {
4277 let pushed = run_sync_push().unwrap_or_else(|e| {
4278 eprintln!("daemon: push error: {e:#}");
4279 json!({"pushed": [], "skipped": [{"error": e.to_string()}]})
4280 });
4281 let pulled = run_sync_pull().unwrap_or_else(|e| {
4282 eprintln!("daemon: pull error: {e:#}");
4283 json!({"written": [], "rejected": [], "total_seen": 0, "error": e.to_string()})
4284 });
4285 let pairs = crate::pending_pair::tick().unwrap_or_else(|e| {
4286 eprintln!("daemon: pending-pair tick error: {e:#}");
4287 json!({"transitions": []})
4288 });
4289
4290 if as_json {
4291 println!(
4292 "{}",
4293 serde_json::to_string(&json!({
4294 "ts": time::OffsetDateTime::now_utc()
4295 .format(&time::format_description::well_known::Rfc3339)
4296 .unwrap_or_default(),
4297 "push": pushed,
4298 "pull": pulled,
4299 "pairs": pairs,
4300 }))?
4301 );
4302 } else {
4303 let pushed_n = pushed["pushed"].as_array().map(|a| a.len()).unwrap_or(0);
4304 let written_n = pulled["written"].as_array().map(|a| a.len()).unwrap_or(0);
4305 let rejected_n = pulled["rejected"].as_array().map(|a| a.len()).unwrap_or(0);
4306 let pair_transitions = pairs["transitions"]
4307 .as_array()
4308 .map(|a| a.len())
4309 .unwrap_or(0);
4310 if pushed_n > 0 || written_n > 0 || rejected_n > 0 || pair_transitions > 0 {
4311 eprintln!(
4312 "daemon: pushed={pushed_n} pulled={written_n} rejected={rejected_n} pair-transitions={pair_transitions}"
4313 );
4314 }
4315 if let Some(arr) = pairs["transitions"].as_array() {
4317 for t in arr {
4318 eprintln!(
4319 " pair {} : {} → {}",
4320 t.get("code").and_then(Value::as_str).unwrap_or("?"),
4321 t.get("from").and_then(Value::as_str).unwrap_or("?"),
4322 t.get("to").and_then(Value::as_str).unwrap_or("?")
4323 );
4324 if let Some(sas) = t.get("sas").and_then(Value::as_str)
4325 && t.get("to").and_then(Value::as_str) == Some("sas_ready")
4326 {
4327 eprintln!(" SAS digits: {}-{}", &sas[..3], &sas[3..]);
4328 eprintln!(
4329 " Run: wire pair-confirm {} {}",
4330 t.get("code").and_then(Value::as_str).unwrap_or("?"),
4331 sas
4332 );
4333 }
4334 }
4335 }
4336 }
4337
4338 if once {
4339 return Ok(());
4340 }
4341 let _ = wake_rx.recv_timeout(interval);
4346 while wake_rx.try_recv().is_ok() {}
4347 }
4348}
4349
4350fn run_sync_push() -> Result<Value> {
4353 let state = config::read_relay_state()?;
4354 let peers = state["peers"].as_object().cloned().unwrap_or_default();
4355 if peers.is_empty() {
4356 return Ok(json!({"pushed": [], "skipped": []}));
4357 }
4358 let outbox_dir = config::outbox_dir()?;
4359 if !outbox_dir.exists() {
4360 return Ok(json!({"pushed": [], "skipped": []}));
4361 }
4362 let mut pushed = Vec::new();
4363 let mut skipped = Vec::new();
4364 for (peer_handle, slot_info) in peers.iter() {
4365 let outbox = outbox_dir.join(format!("{peer_handle}.jsonl"));
4366 if !outbox.exists() {
4367 continue;
4368 }
4369 let url = slot_info["relay_url"].as_str().unwrap_or("");
4370 let slot_id = slot_info["slot_id"].as_str().unwrap_or("");
4371 let slot_token = slot_info["slot_token"].as_str().unwrap_or("");
4372 if url.is_empty() || slot_id.is_empty() || slot_token.is_empty() {
4373 continue;
4374 }
4375 let client = crate::relay_client::RelayClient::new(url);
4376 let body = std::fs::read_to_string(&outbox)?;
4377 for line in body.lines() {
4378 let event: Value = match serde_json::from_str(line) {
4379 Ok(v) => v,
4380 Err(_) => continue,
4381 };
4382 let event_id = event
4383 .get("event_id")
4384 .and_then(Value::as_str)
4385 .unwrap_or("")
4386 .to_string();
4387 match client.post_event(slot_id, slot_token, &event) {
4388 Ok(resp) => {
4389 if resp.status == "duplicate" {
4390 skipped.push(json!({"peer": peer_handle, "event_id": event_id, "reason": "duplicate"}));
4391 } else {
4392 pushed.push(json!({"peer": peer_handle, "event_id": event_id}));
4393 }
4394 }
4395 Err(e) => {
4396 let reason = crate::relay_client::format_transport_error(&e);
4400 skipped
4401 .push(json!({"peer": peer_handle, "event_id": event_id, "reason": reason}));
4402 }
4403 }
4404 }
4405 }
4406 Ok(json!({"pushed": pushed, "skipped": skipped}))
4407}
4408
4409fn run_sync_pull() -> Result<Value> {
4411 let state = config::read_relay_state()?;
4412 let self_state = state.get("self").cloned().unwrap_or(Value::Null);
4413 if self_state.is_null() {
4414 return Ok(json!({"written": [], "rejected": [], "total_seen": 0}));
4415 }
4416 let url = self_state["relay_url"].as_str().unwrap_or("");
4417 let slot_id = self_state["slot_id"].as_str().unwrap_or("");
4418 let slot_token = self_state["slot_token"].as_str().unwrap_or("");
4419 let last_event_id = self_state
4420 .get("last_pulled_event_id")
4421 .and_then(Value::as_str)
4422 .map(str::to_string);
4423 if url.is_empty() {
4424 return Ok(json!({"written": [], "rejected": [], "total_seen": 0}));
4425 }
4426 let client = crate::relay_client::RelayClient::new(url);
4427 let events = client.list_events(slot_id, slot_token, last_event_id.as_deref(), Some(1000))?;
4428 let inbox_dir = config::inbox_dir()?;
4429 config::ensure_dirs()?;
4430
4431 let result = crate::pull::process_events(&events, last_event_id, &inbox_dir)?;
4435
4436 if let Some(eid) = &result.advance_cursor_to {
4438 let eid = eid.clone();
4439 config::update_relay_state(|state| {
4440 if let Some(self_obj) = state.get_mut("self").and_then(Value::as_object_mut) {
4441 self_obj.insert("last_pulled_event_id".into(), Value::String(eid));
4442 }
4443 Ok(())
4444 })?;
4445 }
4446
4447 Ok(json!({
4448 "written": result.written,
4449 "rejected": result.rejected,
4450 "total_seen": events.len(),
4451 "cursor_blocked": result.blocked,
4452 "cursor_advanced_to": result.advance_cursor_to,
4453 }))
4454}
4455
4456fn cmd_pin(card_file: &str, as_json: bool) -> Result<()> {
4459 let body =
4460 std::fs::read_to_string(card_file).with_context(|| format!("reading {card_file}"))?;
4461 let card: Value =
4462 serde_json::from_str(&body).with_context(|| format!("parsing {card_file}"))?;
4463 crate::agent_card::verify_agent_card(&card)
4464 .map_err(|e| anyhow!("peer card signature invalid: {e}"))?;
4465
4466 let mut trust = config::read_trust()?;
4467 crate::trust::add_agent_card_pin(&mut trust, &card, Some("VERIFIED"));
4468
4469 let did = card.get("did").and_then(Value::as_str).unwrap_or("");
4470 let handle = crate::agent_card::display_handle_from_did(did).to_string();
4471 config::write_trust(&trust)?;
4472
4473 if as_json {
4474 println!(
4475 "{}",
4476 serde_json::to_string(&json!({
4477 "handle": handle,
4478 "did": did,
4479 "tier": "VERIFIED",
4480 "pinned": true,
4481 }))?
4482 );
4483 } else {
4484 println!("pinned {handle} ({did}) at tier VERIFIED");
4485 }
4486 Ok(())
4487}
4488
4489fn cmd_pair_host(relay_url: &str, auto_yes: bool, timeout_secs: u64) -> Result<()> {
4492 pair_orchestrate(relay_url, None, "host", auto_yes, timeout_secs)
4493}
4494
4495fn cmd_pair_join(
4496 code_phrase: &str,
4497 relay_url: &str,
4498 auto_yes: bool,
4499 timeout_secs: u64,
4500) -> Result<()> {
4501 pair_orchestrate(
4502 relay_url,
4503 Some(code_phrase),
4504 "guest",
4505 auto_yes,
4506 timeout_secs,
4507 )
4508}
4509
4510fn pair_orchestrate(
4516 relay_url: &str,
4517 code_in: Option<&str>,
4518 role: &str,
4519 auto_yes: bool,
4520 timeout_secs: u64,
4521) -> Result<()> {
4522 use crate::pair_session::{pair_session_finalize, pair_session_open, pair_session_try_sas};
4523
4524 let mut s = pair_session_open(role, relay_url, code_in)?;
4525
4526 if role == "host" {
4527 eprintln!();
4528 eprintln!("share this code phrase with your peer:");
4529 eprintln!();
4530 eprintln!(" {}", s.code);
4531 eprintln!();
4532 eprintln!(
4533 "waiting for peer to run `wire pair-join {} --relay {relay_url}` ...",
4534 s.code
4535 );
4536 } else {
4537 eprintln!();
4538 eprintln!("joined pair-slot on {relay_url} — waiting for host's SPAKE2 message ...");
4539 }
4540
4541 const HEARTBEAT_SECS: u64 = 10;
4546 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
4547 let started = std::time::Instant::now();
4548 let mut last_heartbeat = started;
4549 let formatted = loop {
4550 if let Some(sas) = pair_session_try_sas(&mut s)? {
4551 break sas;
4552 }
4553 let now = std::time::Instant::now();
4554 if now >= deadline {
4555 return Err(anyhow!(
4556 "timeout after {timeout_secs}s waiting for peer's SPAKE2 message"
4557 ));
4558 }
4559 if now.duration_since(last_heartbeat).as_secs() >= HEARTBEAT_SECS {
4560 let elapsed = now.duration_since(started).as_secs();
4561 eprintln!(" ... still waiting ({elapsed}s / {timeout_secs}s)");
4562 last_heartbeat = now;
4563 }
4564 std::thread::sleep(std::time::Duration::from_millis(250));
4565 };
4566
4567 eprintln!();
4568 eprintln!("SAS digits (must match peer's terminal):");
4569 eprintln!();
4570 eprintln!(" {formatted}");
4571 eprintln!();
4572
4573 if !auto_yes {
4576 eprint!("does this match your peer's terminal? [y/N]: ");
4577 use std::io::Write;
4578 std::io::stderr().flush().ok();
4579 let mut input = String::new();
4580 std::io::stdin().read_line(&mut input)?;
4581 let trimmed = input.trim().to_lowercase();
4582 if trimmed != "y" && trimmed != "yes" {
4583 bail!("SAS confirmation declined — aborting pairing");
4584 }
4585 }
4586 s.sas_confirmed = true;
4587
4588 let result = pair_session_finalize(&mut s, timeout_secs)?;
4590
4591 let peer_did = result["paired_with"].as_str().unwrap_or("");
4592 let peer_role = if role == "host" { "guest" } else { "host" };
4593 eprintln!("paired with {peer_did} (peer role: {peer_role})");
4594 eprintln!("peer card pinned at tier VERIFIED");
4595 eprintln!(
4596 "peer relay slot saved to {}",
4597 config::relay_state_path()?.display()
4598 );
4599
4600 println!("{}", serde_json::to_string(&result)?);
4601 Ok(())
4602}
4603
4604fn cmd_pair(
4610 handle: &str,
4611 code: Option<&str>,
4612 relay: &str,
4613 auto_yes: bool,
4614 timeout_secs: u64,
4615 no_setup: bool,
4616) -> Result<()> {
4617 let init_result = crate::pair_session::init_self_idempotent(handle, None, None)?;
4620 let did = init_result
4621 .get("did")
4622 .and_then(|v| v.as_str())
4623 .unwrap_or("(unknown)")
4624 .to_string();
4625 let already = init_result
4626 .get("already_initialized")
4627 .and_then(|v| v.as_bool())
4628 .unwrap_or(false);
4629 if already {
4630 println!("(identity {did} already initialized — reusing)");
4631 } else {
4632 println!("initialized {did}");
4633 }
4634 println!();
4635
4636 match code {
4638 None => {
4639 println!("hosting pair on {relay} (no code = host) ...");
4640 cmd_pair_host(relay, auto_yes, timeout_secs)?;
4641 }
4642 Some(c) => {
4643 println!("joining pair with code {c} on {relay} ...");
4644 cmd_pair_join(c, relay, auto_yes, timeout_secs)?;
4645 }
4646 }
4647
4648 if !no_setup {
4650 println!();
4651 println!("registering wire as MCP server in detected client configs ...");
4652 if let Err(e) = cmd_setup(true) {
4653 eprintln!("warn: setup --apply failed: {e}");
4655 eprintln!(" pair succeeded; you can re-run `wire setup --apply` manually.");
4656 }
4657 }
4658
4659 println!();
4660 println!("pair complete. Next steps:");
4661 println!(" wire daemon start # background sync of inbox/outbox vs relay");
4662 println!(" wire send <peer> claim <msg> # send your peer something");
4663 println!(" wire tail # watch incoming events");
4664 Ok(())
4665}
4666
4667fn cmd_pair_detach(handle: &str, code: Option<&str>, relay: &str) -> Result<()> {
4673 let init_result = crate::pair_session::init_self_idempotent(handle, None, None)?;
4674 let did = init_result
4675 .get("did")
4676 .and_then(|v| v.as_str())
4677 .unwrap_or("(unknown)")
4678 .to_string();
4679 let already = init_result
4680 .get("already_initialized")
4681 .and_then(|v| v.as_bool())
4682 .unwrap_or(false);
4683 if already {
4684 println!("(identity {did} already initialized — reusing)");
4685 } else {
4686 println!("initialized {did}");
4687 }
4688 println!();
4689 match code {
4690 None => cmd_pair_host_detach(relay, false),
4691 Some(c) => cmd_pair_join_detach(c, relay, false),
4692 }
4693}
4694
4695fn cmd_pair_host_detach(relay_url: &str, as_json: bool) -> Result<()> {
4696 if !config::is_initialized()? {
4697 bail!("not initialized — run `wire init <handle>` first");
4698 }
4699 let daemon_spawned = match crate::ensure_up::ensure_daemon_running() {
4700 Ok(b) => b,
4701 Err(e) => {
4702 if !as_json {
4703 eprintln!(
4704 "warn: could not auto-start daemon: {e}; pair will queue but not advance"
4705 );
4706 }
4707 false
4708 }
4709 };
4710 let code = crate::sas::generate_code_phrase();
4711 let code_hash = crate::pair_session::derive_code_hash(&code);
4712 let now = time::OffsetDateTime::now_utc()
4713 .format(&time::format_description::well_known::Rfc3339)
4714 .unwrap_or_default();
4715 let p = crate::pending_pair::PendingPair {
4716 code: code.clone(),
4717 code_hash,
4718 role: "host".to_string(),
4719 relay_url: relay_url.to_string(),
4720 status: "request_host".to_string(),
4721 sas: None,
4722 peer_did: None,
4723 created_at: now,
4724 last_error: None,
4725 pair_id: None,
4726 our_slot_id: None,
4727 our_slot_token: None,
4728 spake2_seed_b64: None,
4729 };
4730 crate::pending_pair::write_pending(&p)?;
4731 if as_json {
4732 println!(
4733 "{}",
4734 serde_json::to_string(&json!({
4735 "state": "queued",
4736 "code_phrase": code,
4737 "relay_url": relay_url,
4738 "role": "host",
4739 "daemon_spawned": daemon_spawned,
4740 }))?
4741 );
4742 } else {
4743 if daemon_spawned {
4744 println!("(started wire daemon in background)");
4745 }
4746 println!("detached pair-host queued. Share this code with your peer:\n");
4747 println!(" {code}\n");
4748 println!("Next steps:");
4749 println!(" wire pair-list # check status");
4750 println!(" wire pair-confirm {code} <digits> # when SAS shows up");
4751 println!(" wire pair-cancel {code} # to abort");
4752 }
4753 Ok(())
4754}
4755
4756fn cmd_pair_join_detach(code_phrase: &str, relay_url: &str, as_json: bool) -> Result<()> {
4757 if !config::is_initialized()? {
4758 bail!("not initialized — run `wire init <handle>` first");
4759 }
4760 let daemon_spawned = match crate::ensure_up::ensure_daemon_running() {
4761 Ok(b) => b,
4762 Err(e) => {
4763 if !as_json {
4764 eprintln!(
4765 "warn: could not auto-start daemon: {e}; pair will queue but not advance"
4766 );
4767 }
4768 false
4769 }
4770 };
4771 let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
4772 let code_hash = crate::pair_session::derive_code_hash(&code);
4773 let now = time::OffsetDateTime::now_utc()
4774 .format(&time::format_description::well_known::Rfc3339)
4775 .unwrap_or_default();
4776 let p = crate::pending_pair::PendingPair {
4777 code: code.clone(),
4778 code_hash,
4779 role: "guest".to_string(),
4780 relay_url: relay_url.to_string(),
4781 status: "request_guest".to_string(),
4782 sas: None,
4783 peer_did: None,
4784 created_at: now,
4785 last_error: None,
4786 pair_id: None,
4787 our_slot_id: None,
4788 our_slot_token: None,
4789 spake2_seed_b64: None,
4790 };
4791 crate::pending_pair::write_pending(&p)?;
4792 if as_json {
4793 println!(
4794 "{}",
4795 serde_json::to_string(&json!({
4796 "state": "queued",
4797 "code_phrase": code,
4798 "relay_url": relay_url,
4799 "role": "guest",
4800 "daemon_spawned": daemon_spawned,
4801 }))?
4802 );
4803 } else {
4804 if daemon_spawned {
4805 println!("(started wire daemon in background)");
4806 }
4807 println!("detached pair-join queued for code {code}.");
4808 println!(
4809 "Run `wire pair-list` to watch for SAS, then `wire pair-confirm {code} <digits>`."
4810 );
4811 }
4812 Ok(())
4813}
4814
4815fn cmd_pair_confirm(code_phrase: &str, typed_digits: &str, as_json: bool) -> Result<()> {
4816 let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
4817 let typed: String = typed_digits
4818 .chars()
4819 .filter(|c| c.is_ascii_digit())
4820 .collect();
4821 if typed.len() != 6 {
4822 bail!(
4823 "expected 6 digits (got {} after stripping non-digits)",
4824 typed.len()
4825 );
4826 }
4827 let mut p = crate::pending_pair::read_pending(&code)?
4828 .ok_or_else(|| anyhow!("no pending pair found for code {code}"))?;
4829 if p.status != "sas_ready" {
4830 bail!(
4831 "pair {code} not in sas_ready state (current: {}). Run `wire pair-list` to see what's going on.",
4832 p.status
4833 );
4834 }
4835 let stored = p
4836 .sas
4837 .as_ref()
4838 .ok_or_else(|| anyhow!("pending file has status=sas_ready but no sas field"))?
4839 .clone();
4840 if stored == typed {
4841 p.status = "confirmed".to_string();
4842 crate::pending_pair::write_pending(&p)?;
4843 if as_json {
4844 println!(
4845 "{}",
4846 serde_json::to_string(&json!({
4847 "state": "confirmed",
4848 "code_phrase": code,
4849 }))?
4850 );
4851 } else {
4852 println!("digits match. Daemon will finalize the handshake on its next tick.");
4853 println!("Run `wire peers` after a few seconds to confirm.");
4854 }
4855 } else {
4856 p.status = "aborted".to_string();
4857 p.last_error = Some(format!(
4858 "SAS digit mismatch (typed {typed}, expected {stored})"
4859 ));
4860 let client = crate::relay_client::RelayClient::new(&p.relay_url);
4861 let _ = client.pair_abandon(&p.code_hash);
4862 crate::pending_pair::write_pending(&p)?;
4863 crate::os_notify::toast(
4864 &format!("wire — pair aborted ({})", p.code),
4865 p.last_error.as_deref().unwrap_or("digits mismatch"),
4866 );
4867 if as_json {
4868 println!(
4869 "{}",
4870 serde_json::to_string(&json!({
4871 "state": "aborted",
4872 "code_phrase": code,
4873 "error": "digits mismatch",
4874 }))?
4875 );
4876 }
4877 bail!("digits mismatch — pair aborted. Re-issue with a fresh `wire pair-host --detach`.");
4878 }
4879 Ok(())
4880}
4881
4882fn cmd_pair_list(as_json: bool, watch: bool, watch_interval_secs: u64) -> Result<()> {
4883 if watch {
4884 return cmd_pair_list_watch(watch_interval_secs);
4885 }
4886 let spake2_items = crate::pending_pair::list_pending()?;
4887 let inbound_items = crate::pending_inbound_pair::list_pending_inbound()?;
4888 if as_json {
4889 println!("{}", serde_json::to_string(&spake2_items)?);
4894 return Ok(());
4895 }
4896 if spake2_items.is_empty() && inbound_items.is_empty() {
4897 println!("no pending pair sessions.");
4898 return Ok(());
4899 }
4900 if !inbound_items.is_empty() {
4903 println!("PENDING INBOUND (v0.5.14 zero-paste pair_drop awaiting your accept)");
4904 println!(
4905 "{:<20} {:<35} {:<25} NEXT STEP",
4906 "PEER", "RELAY", "RECEIVED"
4907 );
4908 for p in &inbound_items {
4909 println!(
4910 "{:<20} {:<35} {:<25} `wire pair-accept {peer}` to accept; `wire pair-reject {peer}` to refuse",
4911 p.peer_handle,
4912 p.peer_relay_url,
4913 p.received_at,
4914 peer = p.peer_handle,
4915 );
4916 }
4917 println!();
4918 }
4919 if !spake2_items.is_empty() {
4920 println!("SPAKE2 SESSIONS");
4921 println!(
4922 "{:<15} {:<8} {:<18} {:<10} NOTE",
4923 "CODE", "ROLE", "STATUS", "SAS"
4924 );
4925 for p in spake2_items {
4926 let sas = p
4927 .sas
4928 .as_ref()
4929 .map(|d| format!("{}-{}", &d[..3], &d[3..]))
4930 .unwrap_or_else(|| "—".to_string());
4931 let note = p
4932 .last_error
4933 .as_deref()
4934 .or(p.peer_did.as_deref())
4935 .unwrap_or("");
4936 println!(
4937 "{:<15} {:<8} {:<18} {:<10} {}",
4938 p.code, p.role, p.status, sas, note
4939 );
4940 }
4941 }
4942 Ok(())
4943}
4944
4945fn cmd_pair_list_watch(interval_secs: u64) -> Result<()> {
4957 use std::collections::HashMap;
4958 use std::io::Write;
4959 let interval = std::time::Duration::from_secs(interval_secs.max(1));
4960 let mut prev: HashMap<String, String> = HashMap::new();
4963 {
4964 let items = crate::pending_pair::list_pending()?;
4965 for p in &items {
4966 println!("{}", serde_json::to_string(&p)?);
4967 prev.insert(p.code.clone(), p.status.clone());
4968 }
4969 let _ = std::io::stdout().flush();
4971 }
4972 loop {
4973 std::thread::sleep(interval);
4974 let items = match crate::pending_pair::list_pending() {
4975 Ok(v) => v,
4976 Err(_) => continue,
4977 };
4978 let mut cur: HashMap<String, String> = HashMap::new();
4979 for p in &items {
4980 cur.insert(p.code.clone(), p.status.clone());
4981 match prev.get(&p.code) {
4982 None => {
4983 println!("{}", serde_json::to_string(&p)?);
4985 }
4986 Some(prev_status) if prev_status != &p.status => {
4987 println!("{}", serde_json::to_string(&p)?);
4989 }
4990 _ => {}
4991 }
4992 }
4993 for code in prev.keys() {
4994 if !cur.contains_key(code) {
4995 println!(
4998 "{}",
4999 serde_json::to_string(&json!({
5000 "code": code,
5001 "status": "removed",
5002 "_synthetic": true,
5003 }))?
5004 );
5005 }
5006 }
5007 let _ = std::io::stdout().flush();
5008 prev = cur;
5009 }
5010}
5011
5012fn cmd_pair_watch(
5016 code_phrase: &str,
5017 target_status: &str,
5018 timeout_secs: u64,
5019 as_json: bool,
5020) -> Result<()> {
5021 let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
5022 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
5023 let mut last_seen_status: Option<String> = None;
5024 loop {
5025 let p_opt = crate::pending_pair::read_pending(&code)?;
5026 let now = std::time::Instant::now();
5027 match p_opt {
5028 None => {
5029 if last_seen_status.is_some() {
5033 if as_json {
5034 println!(
5035 "{}",
5036 serde_json::to_string(&json!({"state": "finalized", "code": code}))?
5037 );
5038 } else {
5039 println!("pair {code} finalized (file removed)");
5040 }
5041 return Ok(());
5042 } else {
5043 if as_json {
5044 println!(
5045 "{}",
5046 serde_json::to_string(&json!({"error": "no such pair", "code": code}))?
5047 );
5048 }
5049 std::process::exit(1);
5050 }
5051 }
5052 Some(p) => {
5053 let cur = p.status.clone();
5054 if Some(cur.clone()) != last_seen_status {
5055 if as_json {
5056 println!("{}", serde_json::to_string(&p)?);
5058 }
5059 last_seen_status = Some(cur.clone());
5060 }
5061 if cur == target_status {
5062 if !as_json {
5063 let sas_str = p
5064 .sas
5065 .as_ref()
5066 .map(|s| format!("{}-{}", &s[..3], &s[3..]))
5067 .unwrap_or_else(|| "—".to_string());
5068 println!("pair {code} reached {target_status} (SAS: {sas_str})");
5069 }
5070 return Ok(());
5071 }
5072 if cur == "aborted" || cur == "aborted_restart" {
5073 if !as_json {
5074 let err = p.last_error.as_deref().unwrap_or("(no detail)");
5075 eprintln!("pair {code} {cur}: {err}");
5076 }
5077 std::process::exit(1);
5078 }
5079 }
5080 }
5081 if now >= deadline {
5082 if !as_json {
5083 eprintln!(
5084 "timeout after {timeout_secs}s waiting for pair {code} to reach {target_status}"
5085 );
5086 }
5087 std::process::exit(2);
5088 }
5089 std::thread::sleep(std::time::Duration::from_millis(250));
5090 }
5091}
5092
5093fn cmd_pair_cancel(code_phrase: &str, as_json: bool) -> Result<()> {
5094 let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
5095 let p = crate::pending_pair::read_pending(&code)?
5096 .ok_or_else(|| anyhow!("no pending pair for code {code}"))?;
5097 let client = crate::relay_client::RelayClient::new(&p.relay_url);
5098 let _ = client.pair_abandon(&p.code_hash);
5099 crate::pending_pair::delete_pending(&code)?;
5100 if as_json {
5101 println!(
5102 "{}",
5103 serde_json::to_string(&json!({
5104 "state": "cancelled",
5105 "code_phrase": code,
5106 }))?
5107 );
5108 } else {
5109 println!("cancelled pending pair {code} (relay slot released, file removed).");
5110 }
5111 Ok(())
5112}
5113
5114fn cmd_pair_abandon(code_phrase: &str, relay_url: &str) -> Result<()> {
5117 let code = crate::sas::parse_code_phrase(code_phrase)?;
5120 let code_hash = crate::pair_session::derive_code_hash(code);
5121 let client = crate::relay_client::RelayClient::new(relay_url);
5122 client.pair_abandon(&code_hash)?;
5123 println!("abandoned pair-slot for code {code_phrase} on {relay_url}");
5124 println!("host can now issue a fresh code; guest can re-join.");
5125 Ok(())
5126}
5127
5128fn cmd_invite(relay: &str, ttl: u64, uses: u32, share: bool, as_json: bool) -> Result<()> {
5131 let url = crate::pair_invite::mint_invite(Some(ttl), uses, Some(relay))?;
5132
5133 let share_payload: Option<Value> = if share {
5136 let client = reqwest::blocking::Client::new();
5137 let single_use = if uses == 1 { Some(1u32) } else { None };
5138 let body = json!({
5139 "invite_url": url,
5140 "ttl_seconds": ttl,
5141 "uses": single_use,
5142 });
5143 let endpoint = format!("{}/v1/invite/register", relay.trim_end_matches('/'));
5144 let resp = client.post(&endpoint).json(&body).send()?;
5145 if !resp.status().is_success() {
5146 let code = resp.status();
5147 let txt = resp.text().unwrap_or_default();
5148 bail!("relay {code} on /v1/invite/register: {txt}");
5149 }
5150 let parsed: Value = resp.json()?;
5151 let token = parsed
5152 .get("token")
5153 .and_then(Value::as_str)
5154 .ok_or_else(|| anyhow::anyhow!("relay reply missing token"))?
5155 .to_string();
5156 let share_url = format!("{}/i/{}", relay.trim_end_matches('/'), token);
5157 let curl_line = format!("curl -fsSL {share_url} | sh");
5158 Some(json!({
5159 "token": token,
5160 "share_url": share_url,
5161 "curl": curl_line,
5162 "expires_unix": parsed.get("expires_unix"),
5163 }))
5164 } else {
5165 None
5166 };
5167
5168 if as_json {
5169 let mut out = json!({
5170 "invite_url": url,
5171 "ttl_secs": ttl,
5172 "uses": uses,
5173 "relay": relay,
5174 });
5175 if let Some(s) = &share_payload {
5176 out["share"] = s.clone();
5177 }
5178 println!("{}", serde_json::to_string(&out)?);
5179 } else if let Some(s) = share_payload {
5180 let curl = s.get("curl").and_then(Value::as_str).unwrap_or("");
5181 eprintln!("# One-curl onboarding. Share this single line — installs wire if missing,");
5182 eprintln!("# accepts the invite, pairs both sides. TTL: {ttl}s. Uses: {uses}.");
5183 println!("{curl}");
5184 } else {
5185 eprintln!("# Share this URL with one peer. Pasting it = pair complete on their side.");
5186 eprintln!("# TTL: {ttl}s. Uses: {uses}.");
5187 println!("{url}");
5188 }
5189 Ok(())
5190}
5191
5192fn cmd_accept(url: &str, as_json: bool) -> Result<()> {
5193 let resolved = if url.starts_with("http://") || url.starts_with("https://") {
5197 let sep = if url.contains('?') { '&' } else { '?' };
5198 let resolve_url = format!("{url}{sep}format=url");
5199 let client = reqwest::blocking::Client::new();
5200 let resp = client
5201 .get(&resolve_url)
5202 .send()
5203 .with_context(|| format!("GET {resolve_url}"))?;
5204 if !resp.status().is_success() {
5205 bail!("could not resolve short URL {url} (HTTP {})", resp.status());
5206 }
5207 let body = resp.text().unwrap_or_default().trim().to_string();
5208 if !body.starts_with("wire://pair?") {
5209 bail!(
5210 "short URL {url} did not resolve to a wire:// invite. \
5211 (got: {}{})",
5212 body.chars().take(80).collect::<String>(),
5213 if body.chars().count() > 80 { "…" } else { "" }
5214 );
5215 }
5216 body
5217 } else {
5218 url.to_string()
5219 };
5220
5221 let result = crate::pair_invite::accept_invite(&resolved)?;
5222 if as_json {
5223 println!("{}", serde_json::to_string(&result)?);
5224 } else {
5225 let did = result
5226 .get("paired_with")
5227 .and_then(Value::as_str)
5228 .unwrap_or("?");
5229 println!("paired with {did}");
5230 println!(
5231 "you can now: wire send {} <kind> <body>",
5232 crate::agent_card::display_handle_from_did(did)
5233 );
5234 }
5235 Ok(())
5236}
5237
5238fn cmd_whois(handle: Option<&str>, as_json: bool, relay_override: Option<&str>) -> Result<()> {
5241 if let Some(h) = handle {
5242 let parsed = crate::pair_profile::parse_handle(h)?;
5243 if config::is_initialized()? {
5246 let card = config::read_agent_card()?;
5247 let local_handle = card
5248 .get("profile")
5249 .and_then(|p| p.get("handle"))
5250 .and_then(Value::as_str)
5251 .map(str::to_string);
5252 if local_handle.as_deref() == Some(h) {
5253 return cmd_whois(None, as_json, None);
5254 }
5255 }
5256 let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)?;
5258 if as_json {
5259 println!("{}", serde_json::to_string(&resolved)?);
5260 } else {
5261 print_resolved_profile(&resolved);
5262 }
5263 return Ok(());
5264 }
5265 let card = config::read_agent_card()?;
5266 if as_json {
5267 let profile = card.get("profile").cloned().unwrap_or(Value::Null);
5268 println!(
5269 "{}",
5270 serde_json::to_string(&json!({
5271 "did": card.get("did").cloned().unwrap_or(Value::Null),
5272 "profile": profile,
5273 }))?
5274 );
5275 } else {
5276 print!("{}", crate::pair_profile::render_self_summary()?);
5277 }
5278 Ok(())
5279}
5280
5281fn print_resolved_profile(resolved: &Value) {
5282 let did = resolved.get("did").and_then(Value::as_str).unwrap_or("?");
5283 let nick = resolved.get("nick").and_then(Value::as_str).unwrap_or("?");
5284 let relay = resolved
5285 .get("relay_url")
5286 .and_then(Value::as_str)
5287 .unwrap_or("");
5288 let slot = resolved
5289 .get("slot_id")
5290 .and_then(Value::as_str)
5291 .unwrap_or("");
5292 let profile = resolved
5293 .get("card")
5294 .and_then(|c| c.get("profile"))
5295 .cloned()
5296 .unwrap_or(Value::Null);
5297 println!("{did}");
5298 println!(" nick: {nick}");
5299 if !relay.is_empty() {
5300 println!(" relay_url: {relay}");
5301 }
5302 if !slot.is_empty() {
5303 println!(" slot_id: {slot}");
5304 }
5305 let pick =
5306 |k: &str| -> Option<String> { profile.get(k).and_then(Value::as_str).map(str::to_string) };
5307 if let Some(s) = pick("display_name") {
5308 println!(" display_name: {s}");
5309 }
5310 if let Some(s) = pick("emoji") {
5311 println!(" emoji: {s}");
5312 }
5313 if let Some(s) = pick("motto") {
5314 println!(" motto: {s}");
5315 }
5316 if let Some(arr) = profile.get("vibe").and_then(Value::as_array) {
5317 let joined: Vec<String> = arr
5318 .iter()
5319 .filter_map(|v| v.as_str().map(str::to_string))
5320 .collect();
5321 println!(" vibe: {}", joined.join(", "));
5322 }
5323 if let Some(s) = pick("pronouns") {
5324 println!(" pronouns: {s}");
5325 }
5326}
5327
5328fn host_of_url(url: &str) -> String {
5336 let no_scheme = url
5337 .trim_start_matches("https://")
5338 .trim_start_matches("http://");
5339 no_scheme
5340 .split('/')
5341 .next()
5342 .unwrap_or("")
5343 .split(':')
5344 .next()
5345 .unwrap_or("")
5346 .to_string()
5347}
5348
5349fn is_known_relay_domain(peer_domain: &str, our_relay_url: &str) -> bool {
5353 const KNOWN_GOOD: &[&str] = &["wireup.net", "wire.laulpogan.com"];
5355 let peer_domain = peer_domain.trim().to_ascii_lowercase();
5356 if KNOWN_GOOD.iter().any(|k| *k == peer_domain) {
5357 return true;
5358 }
5359 let our_host = host_of_url(our_relay_url).to_ascii_lowercase();
5362 if !our_host.is_empty() && our_host == peer_domain {
5363 return true;
5364 }
5365 false
5366}
5367
5368fn resolve_local_session<'a>(
5386 sessions: &'a [crate::session::SessionInfo],
5387 input: &str,
5388) -> Result<&'a crate::session::SessionInfo, ResolveError> {
5389 if let Some(s) = sessions.iter().find(|s| s.name == input) {
5392 return Ok(s);
5393 }
5394 let nick_matches: Vec<&crate::session::SessionInfo> = sessions
5395 .iter()
5396 .filter(|s| {
5397 s.character
5398 .as_ref()
5399 .map(|c| c.nickname == input)
5400 .unwrap_or(false)
5401 })
5402 .collect();
5403 match nick_matches.len() {
5404 0 => Err(ResolveError::NotFound),
5405 1 => Ok(nick_matches[0]),
5406 _ => Err(ResolveError::Ambiguous(
5407 nick_matches.iter().map(|s| s.name.clone()).collect(),
5408 )),
5409 }
5410}
5411
5412#[derive(Debug)]
5413enum ResolveError {
5414 NotFound,
5415 Ambiguous(Vec<String>),
5416}
5417
5418fn resolve_peer_handle(input: &str) -> Result<Option<String>, ResolveError> {
5434 let trust = match config::read_trust() {
5435 Ok(t) => t,
5436 Err(_) => return Ok(None),
5437 };
5438 let agents = match trust.get("agents").and_then(|a| a.as_object()) {
5439 Some(a) => a,
5440 None => return Ok(None),
5441 };
5442 if agents.contains_key(input) {
5443 return Ok(Some(input.to_string()));
5444 }
5445 let mut nick_matches: Vec<String> = Vec::new();
5446 for (handle, agent) in agents.iter() {
5447 let character = match agent.get("card") {
5451 Some(card) => crate::character::Character::from_card(card),
5452 None => match agent.get("did").and_then(Value::as_str) {
5453 Some(did) => crate::character::Character::from_did(did),
5454 None => continue,
5455 },
5456 };
5457 if character.nickname == input {
5458 nick_matches.push(handle.clone());
5459 }
5460 }
5461 match nick_matches.len() {
5462 0 => Ok(None),
5463 1 => Ok(Some(nick_matches.into_iter().next().unwrap())),
5464 _ => Err(ResolveError::Ambiguous(nick_matches)),
5465 }
5466}
5467
5468fn cmd_add_local_sister(sister_name: &str, as_json: bool) -> Result<()> {
5469 let sessions = crate::session::list_sessions()?;
5471 let sister = match resolve_local_session(&sessions, sister_name) {
5472 Ok(s) => s,
5473 Err(ResolveError::NotFound) => bail!(
5474 "no sister session named `{sister_name}` (matched by session name or character nickname). \
5475 Run `wire session list` to see what's available."
5476 ),
5477 Err(ResolveError::Ambiguous(candidates)) => bail!(
5478 "nickname `{sister_name}` is ambiguous — matches {} sessions: {}. \
5479 Disambiguate by passing the session name (one of those listed) instead of the nickname.",
5480 candidates.len(),
5481 candidates.join(", ")
5482 ),
5483 };
5484 if sister.name != sister_name {
5487 eprintln!(
5488 "wire add: resolved nickname `{sister_name}` → session `{}`",
5489 sister.name
5490 );
5491 }
5492
5493 let our_card = config::read_agent_card()
5496 .map_err(|_| anyhow!("not initialized — run `wire init <handle>` first"))?;
5497 let our_did = our_card
5498 .get("did")
5499 .and_then(Value::as_str)
5500 .ok_or_else(|| anyhow!("agent-card missing did"))?
5501 .to_string();
5502 if let Some(sister_did) = sister.did.as_deref()
5503 && sister_did == our_did
5504 {
5505 bail!("refusing to add self (`{sister_name}` is this very session)");
5506 }
5507
5508 let sister_card_path = sister
5510 .home_dir
5511 .join("config")
5512 .join("wire")
5513 .join("agent-card.json");
5514 let sister_card: Value = serde_json::from_slice(
5515 &std::fs::read(&sister_card_path)
5516 .with_context(|| format!("reading sister card {sister_card_path:?}"))?,
5517 )
5518 .with_context(|| format!("parsing sister card {sister_card_path:?}"))?;
5519 let sister_relay_state: Value = std::fs::read(
5520 sister
5521 .home_dir
5522 .join("config")
5523 .join("wire")
5524 .join("relay.json"),
5525 )
5526 .ok()
5527 .and_then(|b| serde_json::from_slice(&b).ok())
5528 .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
5529
5530 let sister_did = sister_card
5531 .get("did")
5532 .and_then(Value::as_str)
5533 .ok_or_else(|| anyhow!("sister card missing did"))?
5534 .to_string();
5535 let sister_handle = crate::agent_card::display_handle_from_did(&sister_did).to_string();
5536
5537 let sister_endpoints = crate::endpoints::self_endpoints(&sister_relay_state);
5541 if sister_endpoints.is_empty() {
5542 bail!(
5543 "sister `{sister_name}` has no endpoints in its relay.json — recreate with `wire session new --local-only` or `--with-local`"
5544 );
5545 }
5546 let sister_local = sister_endpoints
5547 .iter()
5548 .find(|e| e.scope == crate::endpoints::EndpointScope::Local);
5549 let delivery_endpoint = match sister_local {
5550 Some(e) => e.clone(),
5551 None => sister_endpoints[0].clone(),
5552 };
5553
5554 let our_relay_state = config::read_relay_state()?;
5560 let our_endpoints = crate::endpoints::self_endpoints(&our_relay_state);
5561 if our_endpoints.is_empty() {
5562 bail!(
5563 "this session has no endpoints — run `wire session new --local-only` or `wire bind-relay` first"
5564 );
5565 }
5566 let our_advertised = our_endpoints
5567 .iter()
5568 .find(|e| e.scope == crate::endpoints::EndpointScope::Federation)
5569 .cloned()
5570 .unwrap_or_else(|| our_endpoints[0].clone());
5571
5572 let mut trust = config::read_trust()?;
5576 crate::trust::add_agent_card_pin(&mut trust, &sister_card, Some("VERIFIED"));
5577 config::write_trust(&trust)?;
5578 let mut relay_state = config::read_relay_state()?;
5579 crate::endpoints::pin_peer_endpoints(&mut relay_state, &sister_handle, &sister_endpoints)?;
5580 config::write_relay_state(&relay_state)?;
5581
5582 let sk_seed = config::read_private_key()?;
5585 let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
5586 let pk_b64 = our_card
5587 .get("verify_keys")
5588 .and_then(Value::as_object)
5589 .and_then(|m| m.values().next())
5590 .and_then(|v| v.get("key"))
5591 .and_then(Value::as_str)
5592 .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
5593 let pk_bytes = crate::signing::b64decode(pk_b64)?;
5594 let now = time::OffsetDateTime::now_utc()
5595 .format(&time::format_description::well_known::Rfc3339)
5596 .unwrap_or_default();
5597 let mut body = json!({
5598 "card": our_card,
5599 "relay_url": our_advertised.relay_url,
5600 "slot_id": our_advertised.slot_id,
5601 "slot_token": our_advertised.slot_token,
5602 });
5603 body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
5604 let event = json!({
5605 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
5606 "timestamp": now,
5607 "from": our_did,
5608 "to": sister_did,
5609 "type": "pair_drop",
5610 "kind": 1100u32,
5611 "body": body,
5612 });
5613 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
5614 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
5615
5616 let client = crate::relay_client::RelayClient::new(&delivery_endpoint.relay_url);
5620 client
5621 .post_event(
5622 &delivery_endpoint.slot_id,
5623 &delivery_endpoint.slot_token,
5624 &signed,
5625 )
5626 .with_context(|| format!("delivering pair_drop to `{sister_name}`'s local slot"))?;
5627
5628 if as_json {
5629 println!(
5630 "{}",
5631 serde_json::to_string(&json!({
5632 "handle": sister_name,
5633 "paired_with": sister_did,
5634 "peer_handle": sister_handle,
5635 "event_id": event_id,
5636 "delivered_via": match delivery_endpoint.scope {
5637 crate::endpoints::EndpointScope::Local => "local",
5638 crate::endpoints::EndpointScope::Lan => "lan",
5639 crate::endpoints::EndpointScope::Uds => "uds",
5640 crate::endpoints::EndpointScope::Federation => "federation",
5641 },
5642 "status": "drop_sent",
5643 }))?
5644 );
5645 } else {
5646 let scope = match delivery_endpoint.scope {
5647 crate::endpoints::EndpointScope::Local => "local",
5648 crate::endpoints::EndpointScope::Lan => "lan",
5649 crate::endpoints::EndpointScope::Uds => "uds",
5650 crate::endpoints::EndpointScope::Federation => "federation",
5651 };
5652 println!(
5653 "→ found sister `{sister_name}` (did={sister_did})\n→ pinned peer locally\n→ pair_drop delivered to {scope} slot on {}\nawaiting pair_drop_ack from {sister_handle} to complete bilateral pin.",
5654 delivery_endpoint.relay_url
5655 );
5656 }
5657 Ok(())
5658}
5659
5660fn cmd_add(
5661 handle_arg: &str,
5662 relay_override: Option<&str>,
5663 local_sister: bool,
5664 as_json: bool,
5665) -> Result<()> {
5666 if local_sister {
5667 return cmd_add_local_sister(handle_arg, as_json);
5668 }
5669 let parsed = crate::pair_profile::parse_handle(handle_arg)?;
5670
5671 let (our_did, our_relay, our_slot_id, our_slot_token) =
5673 crate::pair_invite::ensure_self_with_relay(relay_override)?;
5674 if our_did == format!("did:wire:{}", parsed.nick) {
5675 bail!("refusing to add self (handle matches own DID)");
5677 }
5678
5679 if let Some(pending) = crate::pending_inbound_pair::read_pending_inbound(&parsed.nick)? {
5689 return cmd_add_accept_pending(
5690 handle_arg,
5691 &parsed.nick,
5692 &pending,
5693 &our_relay,
5694 &our_slot_id,
5695 &our_slot_token,
5696 as_json,
5697 );
5698 }
5699
5700 if !is_known_relay_domain(&parsed.domain, &our_relay) {
5717 eprintln!(
5718 "wire add: WARN unfamiliar relay domain `{}`.",
5719 parsed.domain
5720 );
5721 eprintln!(
5722 " This is NOT `wireup.net` (the default), NOT your own relay (`{}`), ",
5723 host_of_url(&our_relay)
5724 );
5725 eprintln!(
5726 " and not on the known-good list. If you meant `{}@wireup.net`, ",
5727 parsed.nick
5728 );
5729 eprintln!(
5730 " run `wire add {}@wireup.net` instead. Otherwise verify with your",
5731 parsed.nick
5732 );
5733 eprintln!(" peer out-of-band that they actually run a relay at this domain");
5734 eprintln!(" before relying on the pair. (See issue #9.4.)");
5735 }
5736
5737 let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)?;
5739 let peer_card = resolved
5740 .get("card")
5741 .cloned()
5742 .ok_or_else(|| anyhow!("resolved missing card"))?;
5743 let peer_did = resolved
5744 .get("did")
5745 .and_then(Value::as_str)
5746 .ok_or_else(|| anyhow!("resolved missing did"))?
5747 .to_string();
5748 let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
5749 let peer_slot_id = resolved
5750 .get("slot_id")
5751 .and_then(Value::as_str)
5752 .ok_or_else(|| anyhow!("resolved missing slot_id"))?
5753 .to_string();
5754 let peer_relay = resolved
5755 .get("relay_url")
5756 .and_then(Value::as_str)
5757 .map(str::to_string)
5758 .or_else(|| relay_override.map(str::to_string))
5759 .unwrap_or_else(|| format!("https://{}", parsed.domain));
5760
5761 let mut trust = config::read_trust()?;
5763 crate::trust::add_agent_card_pin(&mut trust, &peer_card, Some("VERIFIED"));
5764 config::write_trust(&trust)?;
5765 let mut relay_state = config::read_relay_state()?;
5766 let existing_token = relay_state
5767 .get("peers")
5768 .and_then(|p| p.get(&peer_handle))
5769 .and_then(|p| p.get("slot_token"))
5770 .and_then(Value::as_str)
5771 .map(str::to_string)
5772 .unwrap_or_default();
5773 relay_state["peers"][&peer_handle] = json!({
5774 "relay_url": peer_relay,
5775 "slot_id": peer_slot_id,
5776 "slot_token": existing_token, });
5778 config::write_relay_state(&relay_state)?;
5779
5780 let our_card = config::read_agent_card()?;
5783 let sk_seed = config::read_private_key()?;
5784 let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
5785 let pk_b64 = our_card
5786 .get("verify_keys")
5787 .and_then(Value::as_object)
5788 .and_then(|m| m.values().next())
5789 .and_then(|v| v.get("key"))
5790 .and_then(Value::as_str)
5791 .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
5792 let pk_bytes = crate::signing::b64decode(pk_b64)?;
5793 let now = time::OffsetDateTime::now_utc()
5794 .format(&time::format_description::well_known::Rfc3339)
5795 .unwrap_or_default();
5796 let our_relay_state = config::read_relay_state().unwrap_or_else(|_| json!({}));
5801 let our_endpoints = crate::endpoints::self_endpoints(&our_relay_state);
5802 let mut body = json!({
5803 "card": our_card,
5804 "relay_url": our_relay,
5805 "slot_id": our_slot_id,
5806 "slot_token": our_slot_token,
5807 });
5808 if !our_endpoints.is_empty() {
5809 body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
5810 }
5811 let event = json!({
5812 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
5813 "timestamp": now,
5814 "from": our_did,
5815 "to": peer_did,
5816 "type": "pair_drop",
5817 "kind": 1100u32,
5818 "body": body,
5819 });
5820 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
5821
5822 let client = crate::relay_client::RelayClient::new(&peer_relay);
5824 let resp = client.handle_intro(&parsed.nick, &signed)?;
5825 let event_id = signed
5826 .get("event_id")
5827 .and_then(Value::as_str)
5828 .unwrap_or("")
5829 .to_string();
5830
5831 if as_json {
5832 println!(
5833 "{}",
5834 serde_json::to_string(&json!({
5835 "handle": handle_arg,
5836 "paired_with": peer_did,
5837 "peer_handle": peer_handle,
5838 "event_id": event_id,
5839 "drop_response": resp,
5840 "status": "drop_sent",
5841 }))?
5842 );
5843 } else {
5844 println!(
5845 "→ 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."
5846 );
5847 }
5848 Ok(())
5849}
5850
5851fn cmd_add_accept_pending(
5858 handle_arg: &str,
5859 peer_nick: &str,
5860 pending: &crate::pending_inbound_pair::PendingInboundPair,
5861 _our_relay: &str,
5862 _our_slot_id: &str,
5863 _our_slot_token: &str,
5864 as_json: bool,
5865) -> Result<()> {
5866 let mut trust = config::read_trust()?;
5869 crate::trust::add_agent_card_pin(&mut trust, &pending.peer_card, Some("VERIFIED"));
5870 config::write_trust(&trust)?;
5871
5872 let mut relay_state = config::read_relay_state()?;
5878 let endpoints_to_pin = if pending.peer_endpoints.is_empty() {
5879 vec![crate::endpoints::Endpoint::federation(
5880 pending.peer_relay_url.clone(),
5881 pending.peer_slot_id.clone(),
5882 pending.peer_slot_token.clone(),
5883 )]
5884 } else {
5885 pending.peer_endpoints.clone()
5886 };
5887 crate::endpoints::pin_peer_endpoints(
5888 &mut relay_state,
5889 &pending.peer_handle,
5890 &endpoints_to_pin,
5891 )?;
5892 config::write_relay_state(&relay_state)?;
5893
5894 crate::pair_invite::send_pair_drop_ack(
5896 &pending.peer_handle,
5897 &pending.peer_relay_url,
5898 &pending.peer_slot_id,
5899 &pending.peer_slot_token,
5900 )
5901 .with_context(|| {
5902 format!(
5903 "pair_drop_ack send to {} @ {} slot {} failed",
5904 pending.peer_handle, pending.peer_relay_url, pending.peer_slot_id
5905 )
5906 })?;
5907
5908 crate::pending_inbound_pair::consume_pending_inbound(peer_nick)?;
5910
5911 if as_json {
5912 println!(
5913 "{}",
5914 serde_json::to_string(&json!({
5915 "handle": handle_arg,
5916 "paired_with": pending.peer_did,
5917 "peer_handle": pending.peer_handle,
5918 "status": "bilateral_accepted",
5919 "via": "pending_inbound",
5920 }))?
5921 );
5922 } else {
5923 println!(
5924 "→ 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} \"...\"`.",
5925 peer = pending.peer_handle,
5926 );
5927 }
5928 Ok(())
5929}
5930
5931fn cmd_pair_accept(peer_nick: &str, as_json: bool) -> Result<()> {
5938 let nick = crate::agent_card::bare_handle(peer_nick);
5939 let pending = crate::pending_inbound_pair::read_pending_inbound(nick)?.ok_or_else(|| {
5940 anyhow!(
5941 "no pending pair request from {nick}. Run `wire pair-list-inbound` to see who is waiting, \
5942 or use `wire add <peer>@<relay>` to send a fresh outbound pair request."
5943 )
5944 })?;
5945 let (_our_did, our_relay, our_slot_id, our_slot_token) =
5946 crate::pair_invite::ensure_self_with_relay(None)?;
5947 let handle_arg = format!("{}@{}", pending.peer_handle, pending.peer_relay_url);
5948 cmd_add_accept_pending(
5949 &handle_arg,
5950 nick,
5951 &pending,
5952 &our_relay,
5953 &our_slot_id,
5954 &our_slot_token,
5955 as_json,
5956 )
5957}
5958
5959fn cmd_pair_list_inbound(as_json: bool) -> Result<()> {
5962 let items = crate::pending_inbound_pair::list_pending_inbound()?;
5963 if as_json {
5964 println!("{}", serde_json::to_string(&items)?);
5965 return Ok(());
5966 }
5967 if items.is_empty() {
5968 println!("no pending inbound pair requests.");
5969 return Ok(());
5970 }
5971 println!("{:<20} {:<35} {:<25} DID", "PEER", "RELAY", "RECEIVED");
5972 for p in items {
5973 println!(
5974 "{:<20} {:<35} {:<25} {}",
5975 p.peer_handle, p.peer_relay_url, p.received_at, p.peer_did,
5976 );
5977 }
5978 println!("→ accept with `wire pair-accept <peer>`; refuse with `wire pair-reject <peer>`.");
5979 Ok(())
5980}
5981
5982fn cmd_pair_reject(peer_nick: &str, as_json: bool) -> Result<()> {
5986 let nick = crate::agent_card::bare_handle(peer_nick);
5987 let existed = crate::pending_inbound_pair::read_pending_inbound(nick)?;
5988 crate::pending_inbound_pair::consume_pending_inbound(nick)?;
5989
5990 if as_json {
5991 println!(
5992 "{}",
5993 serde_json::to_string(&json!({
5994 "peer": nick,
5995 "rejected": existed.is_some(),
5996 "had_pending": existed.is_some(),
5997 }))?
5998 );
5999 } else if existed.is_some() {
6000 println!(
6001 "→ rejected pending pair from {nick}\n→ pending-inbound record deleted; no ack sent."
6002 );
6003 } else {
6004 println!("no pending pair from {nick} — nothing to reject");
6005 }
6006 Ok(())
6007}
6008
6009fn cmd_mesh(cmd: MeshCommand) -> Result<()> {
6020 match cmd {
6021 MeshCommand::Status { stale_secs, json } => cmd_session_mesh_status(stale_secs, json),
6022 MeshCommand::Broadcast {
6023 kind,
6024 scope,
6025 exclude,
6026 noreply,
6027 body,
6028 json,
6029 } => cmd_mesh_broadcast(&kind, &scope, &exclude, noreply, &body, json),
6030 MeshCommand::Role { action } => cmd_mesh_role(action),
6031 MeshCommand::Route {
6032 role,
6033 strategy,
6034 exclude,
6035 kind,
6036 body,
6037 json,
6038 } => cmd_mesh_route(&role, &strategy, &exclude, &kind, &body, json),
6039 }
6040}
6041
6042fn cmd_mesh_route(
6047 role: &str,
6048 strategy: &str,
6049 exclude: &[String],
6050 kind: &str,
6051 body_arg: &str,
6052 as_json: bool,
6053) -> Result<()> {
6054 use std::time::Instant;
6055
6056 if !config::is_initialized()? {
6057 bail!("not initialized — run `wire init <handle>` first");
6058 }
6059 let strategy = strategy.to_ascii_lowercase();
6060 if !matches!(strategy.as_str(), "round-robin" | "first" | "random") {
6061 bail!("unknown strategy `{strategy}` — use round-robin | first | random");
6062 }
6063
6064 let state = config::read_relay_state()?;
6067 let pinned: std::collections::BTreeSet<String> = state["peers"]
6068 .as_object()
6069 .map(|m| m.keys().cloned().collect())
6070 .unwrap_or_default();
6071
6072 let exclude_set: std::collections::HashSet<&str> = exclude.iter().map(String::as_str).collect();
6073
6074 let sessions = crate::session::list_sessions()?;
6079 let mut candidates: Vec<(String, Option<String>)> = Vec::new(); for s in &sessions {
6081 let handle = match s.handle.as_ref() {
6082 Some(h) => h.clone(),
6083 None => continue,
6084 };
6085 if exclude_set.contains(handle.as_str()) {
6086 continue;
6087 }
6088 if !pinned.contains(&handle) {
6089 continue;
6090 }
6091 let card_path = s
6092 .home_dir
6093 .join("config")
6094 .join("wire")
6095 .join("agent-card.json");
6096 let card_role = std::fs::read(&card_path)
6097 .ok()
6098 .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
6099 .and_then(|c| {
6100 c.get("profile")
6101 .and_then(|p| p.get("role"))
6102 .and_then(Value::as_str)
6103 .map(str::to_string)
6104 });
6105 if card_role.as_deref() == Some(role) {
6106 candidates.push((handle, s.did.clone()));
6107 }
6108 }
6109
6110 candidates.sort_by(|a, b| a.0.cmp(&b.0));
6111 candidates.dedup_by(|a, b| a.0 == b.0);
6112
6113 if candidates.is_empty() {
6114 bail!(
6115 "no pinned sister with role=`{role}` (run `wire mesh role list` to see what's available)"
6116 );
6117 }
6118
6119 let chosen = match strategy.as_str() {
6120 "first" => candidates[0].clone(),
6121 "random" => {
6122 use rand::Rng;
6123 let idx = rand::thread_rng().gen_range(0..candidates.len());
6124 candidates[idx].clone()
6125 }
6126 "round-robin" => {
6127 let cursor_path = mesh_route_cursor_path()?;
6132 let mut cursors: std::collections::BTreeMap<String, String> =
6133 read_mesh_route_cursors(&cursor_path);
6134 let last = cursors.get(role).cloned();
6135 let pick = match last {
6136 None => candidates[0].clone(),
6137 Some(last_h) => candidates
6138 .iter()
6139 .find(|(h, _)| h.as_str() > last_h.as_str())
6140 .cloned()
6141 .unwrap_or_else(|| candidates[0].clone()),
6142 };
6143 cursors.insert(role.to_string(), pick.0.clone());
6144 write_mesh_route_cursors(&cursor_path, &cursors)?;
6145 pick
6146 }
6147 _ => unreachable!(),
6148 };
6149
6150 let (chosen_handle, _chosen_did) = chosen;
6151
6152 let body_value: Value = if body_arg == "-" {
6154 use std::io::Read;
6155 let mut raw = String::new();
6156 std::io::stdin()
6157 .read_to_string(&mut raw)
6158 .with_context(|| "reading body from stdin")?;
6159 serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
6160 } else if let Some(path) = body_arg.strip_prefix('@') {
6161 let raw =
6162 std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
6163 serde_json::from_str(&raw).unwrap_or(Value::String(raw))
6164 } else {
6165 Value::String(body_arg.to_string())
6166 };
6167
6168 let sk_seed = config::read_private_key()?;
6169 let card = config::read_agent_card()?;
6170 let did = card
6171 .get("did")
6172 .and_then(Value::as_str)
6173 .ok_or_else(|| anyhow!("agent-card missing did"))?
6174 .to_string();
6175 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
6176 let pk_b64 = card
6177 .get("verify_keys")
6178 .and_then(Value::as_object)
6179 .and_then(|m| m.values().next())
6180 .and_then(|v| v.get("key"))
6181 .and_then(Value::as_str)
6182 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
6183 let pk_bytes = crate::signing::b64decode(pk_b64)?;
6184
6185 let kind_id = parse_kind(kind)?;
6186 let now_iso = time::OffsetDateTime::now_utc()
6187 .format(&time::format_description::well_known::Rfc3339)
6188 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
6189
6190 let event = json!({
6191 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
6192 "timestamp": now_iso,
6193 "from": did,
6194 "to": format!("did:wire:{chosen_handle}"),
6195 "type": kind,
6196 "kind": kind_id,
6197 "body": json!({
6198 "content": body_value,
6199 "routed_via": {
6200 "role": role,
6201 "strategy": strategy,
6202 },
6203 }),
6204 });
6205 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)
6206 .map_err(|e| anyhow!("sign_message_v31 failed: {e:?}"))?;
6207 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
6208
6209 let line = serde_json::to_vec(&signed)?;
6210 config::append_outbox_record(&chosen_handle, &line)?;
6211
6212 let endpoints = crate::endpoints::peer_endpoints_in_priority_order(&state, &chosen_handle);
6213 if endpoints.is_empty() {
6214 bail!(
6215 "no reachable endpoint pinned for `{chosen_handle}` (the role matched, but we can't push)"
6216 );
6217 }
6218 let start = Instant::now();
6219 let mut delivered = false;
6220 let mut last_err: Option<String> = None;
6221 let mut via_scope: Option<String> = None;
6222 for ep in &endpoints {
6223 match crate::relay_client::post_event_to_endpoint(ep, &signed) {
6228 Ok(_) => {
6229 delivered = true;
6230 via_scope = Some(
6231 match ep.scope {
6232 crate::endpoints::EndpointScope::Local => "local",
6233 crate::endpoints::EndpointScope::Lan => "lan",
6234 crate::endpoints::EndpointScope::Uds => "uds",
6235 crate::endpoints::EndpointScope::Federation => "federation",
6236 }
6237 .to_string(),
6238 );
6239 break;
6240 }
6241 Err(e) => last_err = Some(format!("{e:#}")),
6242 }
6243 }
6244 let rtt_ms = start.elapsed().as_millis() as u64;
6245
6246 let summary = json!({
6247 "role": role,
6248 "strategy": strategy,
6249 "routed_to": chosen_handle,
6250 "event_id": event_id,
6251 "delivered": delivered,
6252 "delivered_via": via_scope,
6253 "rtt_ms": rtt_ms,
6254 "candidates": candidates.iter().map(|(h, _)| h.clone()).collect::<Vec<_>>(),
6255 "error": last_err,
6256 });
6257
6258 if as_json {
6259 println!("{}", serde_json::to_string(&summary)?);
6260 } else if delivered {
6261 let via = via_scope.as_deref().unwrap_or("?");
6262 println!("wire mesh route: {role} → {chosen_handle} ({rtt_ms}ms, {via})");
6263 } else {
6264 let err = last_err.as_deref().unwrap_or("no endpoints reachable");
6265 bail!("delivery to `{chosen_handle}` failed: {err}");
6266 }
6267 Ok(())
6268}
6269
6270fn mesh_route_cursor_path() -> Result<std::path::PathBuf> {
6271 Ok(config::state_dir()?.join("mesh-route-cursor.json"))
6272}
6273
6274fn read_mesh_route_cursors(path: &std::path::Path) -> std::collections::BTreeMap<String, String> {
6275 std::fs::read(path)
6276 .ok()
6277 .and_then(|b| serde_json::from_slice(&b).ok())
6278 .unwrap_or_default()
6279}
6280
6281fn write_mesh_route_cursors(
6282 path: &std::path::Path,
6283 cursors: &std::collections::BTreeMap<String, String>,
6284) -> Result<()> {
6285 if let Some(parent) = path.parent() {
6286 std::fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
6287 }
6288 let body = serde_json::to_vec_pretty(cursors)?;
6289 std::fs::write(path, body).with_context(|| format!("writing {path:?}"))?;
6290 Ok(())
6291}
6292
6293fn cmd_mesh_role(action: MeshRoleAction) -> Result<()> {
6298 match action {
6299 MeshRoleAction::Set { role, json } => {
6300 validate_role_tag(&role)?;
6301 let new_profile =
6302 crate::pair_profile::write_profile_field("role", Value::String(role.clone()))?;
6303 if json {
6304 println!(
6305 "{}",
6306 serde_json::to_string(&json!({
6307 "role": role,
6308 "profile": new_profile,
6309 }))?
6310 );
6311 } else {
6312 println!("self role = {role} (signed into agent-card)");
6313 }
6314 }
6315 MeshRoleAction::Get { peer, json } => {
6316 let (who, role) = match peer.as_deref() {
6317 None => {
6318 let card = config::read_agent_card()?;
6319 let role = card
6320 .get("profile")
6321 .and_then(|p| p.get("role"))
6322 .and_then(Value::as_str)
6323 .map(str::to_string);
6324 let who = card
6325 .get("did")
6326 .and_then(Value::as_str)
6327 .map(|d| crate::agent_card::display_handle_from_did(d).to_string())
6328 .unwrap_or_else(|| "self".to_string());
6329 (who, role)
6330 }
6331 Some(handle) => {
6332 let bare = crate::agent_card::bare_handle(handle).to_string();
6333 let trust = config::read_trust()?;
6334 let role = trust
6335 .get("agents")
6336 .and_then(|a| a.get(&bare))
6337 .and_then(|a| a.get("card"))
6338 .and_then(|c| c.get("profile"))
6339 .and_then(|p| p.get("role"))
6340 .and_then(Value::as_str)
6341 .map(str::to_string);
6342 (bare, role)
6343 }
6344 };
6345 if json {
6346 println!(
6347 "{}",
6348 serde_json::to_string(&json!({
6349 "handle": who,
6350 "role": role,
6351 }))?
6352 );
6353 } else {
6354 match role {
6355 Some(r) => println!("{who}: {r}"),
6356 None => println!("{who}: (unset)"),
6357 }
6358 }
6359 }
6360 MeshRoleAction::List { json } => {
6361 let mut self_did: Option<String> = None;
6362 if let Ok(card) = config::read_agent_card() {
6363 self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
6364 }
6365 let sessions = crate::session::list_sessions()?;
6366 let mut rows: Vec<Value> = Vec::new();
6367 for s in &sessions {
6368 let card_path = s
6369 .home_dir
6370 .join("config")
6371 .join("wire")
6372 .join("agent-card.json");
6373 let role = std::fs::read(&card_path)
6374 .ok()
6375 .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
6376 .and_then(|c| {
6377 c.get("profile")
6378 .and_then(|p| p.get("role"))
6379 .and_then(Value::as_str)
6380 .map(str::to_string)
6381 });
6382 let is_self = match (&self_did, &s.did) {
6383 (Some(a), Some(b)) => a == b,
6384 _ => false,
6385 };
6386 rows.push(json!({
6387 "name": s.name,
6388 "handle": s.handle,
6389 "role": role,
6390 "self": is_self,
6391 }));
6392 }
6393 rows.sort_by(|a, b| {
6394 a["name"]
6395 .as_str()
6396 .unwrap_or("")
6397 .cmp(b["name"].as_str().unwrap_or(""))
6398 });
6399 if json {
6400 println!("{}", serde_json::to_string(&json!({"sessions": rows}))?);
6401 } else if rows.is_empty() {
6402 println!("no sister sessions on this machine.");
6403 } else {
6404 println!("SISTER ROLES (this machine):");
6405 for r in &rows {
6406 let name = r["name"].as_str().unwrap_or("?");
6407 let role = r["role"].as_str().unwrap_or("(unset)");
6408 let marker = if r["self"].as_bool().unwrap_or(false) {
6409 " ← you"
6410 } else {
6411 ""
6412 };
6413 println!(" {name:<24} {role}{marker}");
6414 }
6415 }
6416 }
6417 MeshRoleAction::Clear { json } => {
6418 let new_profile = crate::pair_profile::write_profile_field("role", Value::Null)?;
6419 if json {
6420 println!(
6421 "{}",
6422 serde_json::to_string(&json!({
6423 "cleared": true,
6424 "profile": new_profile,
6425 }))?
6426 );
6427 } else {
6428 println!("self role cleared");
6429 }
6430 }
6431 }
6432 Ok(())
6433}
6434
6435fn validate_role_tag(role: &str) -> Result<()> {
6440 if role.is_empty() {
6441 bail!("role must not be empty (use `wire mesh role --clear` to unset)");
6442 }
6443 if role.len() > 32 {
6444 bail!("role too long ({} chars; max 32)", role.len());
6445 }
6446 for c in role.chars() {
6447 if !(c.is_ascii_alphanumeric() || c == '-' || c == '_') {
6448 bail!("role contains illegal char {c:?} (allowed: A-Z a-z 0-9 - _)");
6449 }
6450 }
6451 Ok(())
6452}
6453
6454fn cmd_mesh_broadcast(
6474 kind: &str,
6475 scope_str: &str,
6476 exclude: &[String],
6477 _noreply: bool,
6478 body_arg: &str,
6479 as_json: bool,
6480) -> Result<()> {
6481 use std::time::Instant;
6482
6483 if !config::is_initialized()? {
6484 bail!("not initialized — run `wire init <handle>` first");
6485 }
6486
6487 let scope = match scope_str {
6488 "local" => crate::endpoints::EndpointScope::Local,
6489 "federation" => crate::endpoints::EndpointScope::Federation,
6490 "both" => {
6491 crate::endpoints::EndpointScope::Local
6495 }
6496 other => bail!("unknown scope `{other}` — use local | federation | both"),
6497 };
6498 let any_scope = scope_str == "both";
6499
6500 let state = config::read_relay_state()?;
6501 let peers = state["peers"].as_object().cloned().unwrap_or_default();
6502 if peers.is_empty() {
6503 bail!("no peers pinned — run `wire accept <invite-url>` or `wire pair-accept` first");
6504 }
6505
6506 let exclude_set: std::collections::HashSet<&str> = exclude.iter().map(String::as_str).collect();
6507
6508 struct Target {
6512 handle: String,
6513 endpoints: Vec<crate::endpoints::Endpoint>,
6514 }
6515 let mut targets: Vec<Target> = Vec::new();
6516 let mut skipped_wrong_scope: Vec<String> = Vec::new();
6517 let mut skipped_excluded: Vec<String> = Vec::new();
6518 for handle in peers.keys() {
6519 if exclude_set.contains(handle.as_str()) {
6520 skipped_excluded.push(handle.clone());
6521 continue;
6522 }
6523 let ordered = crate::endpoints::peer_endpoints_in_priority_order(&state, handle);
6524 let filtered: Vec<crate::endpoints::Endpoint> = ordered
6525 .into_iter()
6526 .filter(|ep| any_scope || ep.scope == scope)
6527 .collect();
6528 if filtered.is_empty() {
6529 skipped_wrong_scope.push(handle.clone());
6530 continue;
6531 }
6532 targets.push(Target {
6533 handle: handle.clone(),
6534 endpoints: filtered,
6535 });
6536 }
6537
6538 if targets.is_empty() {
6539 bail!(
6540 "no peers matched scope=`{scope_str}` after exclude filter ({} excluded, {} wrong-scope)",
6541 skipped_excluded.len(),
6542 skipped_wrong_scope.len()
6543 );
6544 }
6545
6546 let sk_seed = config::read_private_key()?;
6548 let card = config::read_agent_card()?;
6549 let did = card
6550 .get("did")
6551 .and_then(Value::as_str)
6552 .ok_or_else(|| anyhow!("agent-card missing did"))?
6553 .to_string();
6554 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
6555 let pk_b64 = card
6556 .get("verify_keys")
6557 .and_then(Value::as_object)
6558 .and_then(|m| m.values().next())
6559 .and_then(|v| v.get("key"))
6560 .and_then(Value::as_str)
6561 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
6562 let pk_bytes = crate::signing::b64decode(pk_b64)?;
6563
6564 let body_value: Value = if body_arg == "-" {
6565 use std::io::Read;
6566 let mut raw = String::new();
6567 std::io::stdin()
6568 .read_to_string(&mut raw)
6569 .with_context(|| "reading body from stdin")?;
6570 serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
6571 } else if let Some(path) = body_arg.strip_prefix('@') {
6572 let raw =
6573 std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
6574 serde_json::from_str(&raw).unwrap_or(Value::String(raw))
6575 } else {
6576 Value::String(body_arg.to_string())
6577 };
6578
6579 let kind_id = parse_kind(kind)?;
6580 let now_iso = time::OffsetDateTime::now_utc()
6581 .format(&time::format_description::well_known::Rfc3339)
6582 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
6583
6584 let broadcast_id = generate_broadcast_id();
6585 let target_count = targets.len();
6586
6587 let mut signed_per_peer: Vec<(String, Vec<crate::endpoints::Endpoint>, Value, String)> =
6591 Vec::with_capacity(targets.len());
6592 for t in &targets {
6593 let body = json!({
6594 "content": body_value,
6595 "broadcast_id": broadcast_id,
6596 "broadcast_target_count": target_count,
6597 });
6598 let event = json!({
6599 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
6600 "timestamp": now_iso,
6601 "from": did,
6602 "to": format!("did:wire:{}", t.handle),
6603 "type": kind,
6604 "kind": kind_id,
6605 "body": body,
6606 });
6607 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)
6608 .map_err(|e| anyhow!("sign_message_v31 failed for `{}`: {e:?}", t.handle))?;
6609 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
6610 signed_per_peer.push((t.handle.clone(), t.endpoints.clone(), signed, event_id));
6611 }
6612
6613 for (peer, _, signed, _) in &signed_per_peer {
6617 let line = serde_json::to_vec(signed)?;
6618 config::append_outbox_record(peer, &line)?;
6619 }
6620
6621 use std::sync::mpsc;
6625 let (tx, rx) = mpsc::channel::<Value>();
6626 std::thread::scope(|s| {
6627 for (peer, endpoints, signed, event_id) in &signed_per_peer {
6628 let tx = tx.clone();
6629 let peer = peer.clone();
6630 let event_id = event_id.clone();
6631 let endpoints = endpoints.clone();
6632 let signed = signed.clone();
6633 s.spawn(move || {
6634 let start = Instant::now();
6635 let mut delivered = false;
6636 let mut last_err: Option<String> = None;
6637 let mut delivered_via: Option<String> = None;
6638 for ep in &endpoints {
6639 match crate::relay_client::post_event_to_endpoint(ep, &signed) {
6644 Ok(_) => {
6645 delivered = true;
6646 delivered_via = Some(
6647 match ep.scope {
6648 crate::endpoints::EndpointScope::Local => "local",
6649 crate::endpoints::EndpointScope::Lan => "lan",
6650 crate::endpoints::EndpointScope::Uds => "uds",
6651 crate::endpoints::EndpointScope::Federation => "federation",
6652 }
6653 .to_string(),
6654 );
6655 break;
6656 }
6657 Err(e) => last_err = Some(format!("{e:#}")),
6658 }
6659 }
6660 let rtt_ms = start.elapsed().as_millis() as u64;
6661 let _ = tx.send(json!({
6662 "peer": peer,
6663 "event_id": event_id,
6664 "delivered": delivered,
6665 "delivered_via": delivered_via,
6666 "rtt_ms": rtt_ms,
6667 "error": last_err,
6668 }));
6669 });
6670 }
6671 });
6672 drop(tx);
6673
6674 let mut results: Vec<Value> = rx.iter().collect();
6675 results.sort_by(|a, b| {
6676 a["peer"]
6677 .as_str()
6678 .unwrap_or("")
6679 .cmp(b["peer"].as_str().unwrap_or(""))
6680 });
6681
6682 let delivered = results
6683 .iter()
6684 .filter(|r| r["delivered"].as_bool().unwrap_or(false))
6685 .count();
6686 let failed = results.len() - delivered;
6687
6688 let summary = json!({
6689 "broadcast_id": broadcast_id,
6690 "kind": kind,
6691 "scope": scope_str,
6692 "target_count": target_count,
6693 "delivered": delivered,
6694 "failed": failed,
6695 "skipped_excluded": skipped_excluded,
6696 "skipped_wrong_scope": skipped_wrong_scope,
6697 "results": results,
6698 });
6699
6700 if as_json {
6701 println!("{}", serde_json::to_string(&summary)?);
6702 return Ok(());
6703 }
6704
6705 println!("wire mesh broadcast: scope={scope_str} → {target_count} pinned peer(s)");
6706 for r in &results {
6707 let peer = r["peer"].as_str().unwrap_or("?");
6708 let delivered = r["delivered"].as_bool().unwrap_or(false);
6709 let rtt = r["rtt_ms"].as_u64().unwrap_or(0);
6710 let via = r["delivered_via"].as_str().unwrap_or("");
6711 if delivered {
6712 println!(" {peer:<24} ✓ delivered ({rtt}ms, {via})");
6713 } else {
6714 let err = r["error"].as_str().unwrap_or("?");
6715 println!(" {peer:<24} ✗ failed — {err}");
6716 }
6717 }
6718 if !skipped_excluded.is_empty() {
6719 println!(" excluded: {}", skipped_excluded.join(", "));
6720 }
6721 if !skipped_wrong_scope.is_empty() {
6722 println!(
6723 " skipped (wrong scope): {}",
6724 skipped_wrong_scope.join(", ")
6725 );
6726 }
6727 println!("broadcast_id: {broadcast_id}");
6728 Ok(())
6729}
6730
6731fn generate_broadcast_id() -> String {
6735 use rand::RngCore;
6736 let mut buf = [0u8; 16];
6737 rand::thread_rng().fill_bytes(&mut buf);
6738 let h = hex::encode(buf);
6739 format!(
6740 "{}-{}-{}-{}-{}",
6741 &h[0..8],
6742 &h[8..12],
6743 &h[12..16],
6744 &h[16..20],
6745 &h[20..32],
6746 )
6747}
6748
6749fn cmd_session(cmd: SessionCommand) -> Result<()> {
6750 match cmd {
6751 SessionCommand::New {
6752 name,
6753 relay,
6754 with_local,
6755 local_relay,
6756 with_lan,
6757 lan_relay,
6758 with_uds,
6759 uds_socket,
6760 no_daemon,
6761 local_only,
6762 json,
6763 } => cmd_session_new(
6764 name.as_deref(),
6765 &relay,
6766 with_local,
6767 &local_relay,
6768 with_lan,
6769 lan_relay.as_deref(),
6770 with_uds,
6771 uds_socket.as_deref(),
6772 no_daemon,
6773 local_only,
6774 json,
6775 ),
6776 SessionCommand::List { json } => cmd_session_list(json),
6777 SessionCommand::ListLocal { json } => cmd_session_list_local(json),
6778 SessionCommand::PairAllLocal {
6779 settle_secs,
6780 federation_relay,
6781 json,
6782 } => cmd_session_pair_all_local(settle_secs, &federation_relay, json),
6783 SessionCommand::MeshStatus { stale_secs, json } => {
6784 cmd_session_mesh_status(stale_secs, json)
6785 }
6786 SessionCommand::Env { name, json } => cmd_session_env(name.as_deref(), json),
6787 SessionCommand::Current { json } => cmd_session_current(json),
6788 SessionCommand::Destroy { name, force, json } => cmd_session_destroy(&name, force, json),
6789 }
6790}
6791
6792fn resolve_session_name(name: Option<&str>) -> Result<String> {
6793 if let Some(n) = name {
6794 return Ok(crate::session::sanitize_name(n));
6795 }
6796 let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
6797 let registry = crate::session::read_registry().unwrap_or_default();
6798 Ok(crate::session::derive_name_from_cwd(&cwd, ®istry))
6799}
6800
6801#[allow(clippy::too_many_arguments)] fn cmd_session_new(
6805 name_arg: Option<&str>,
6806 relay: &str,
6807 with_local: bool,
6808 local_relay: &str,
6809 with_lan: bool,
6810 lan_relay: Option<&str>,
6811 with_uds: bool,
6812 uds_socket: Option<&std::path::Path>,
6813 no_daemon: bool,
6814 local_only: bool,
6815 as_json: bool,
6816) -> Result<()> {
6817 let with_local = with_local || local_only;
6820 if with_lan && lan_relay.is_none() {
6822 bail!("--with-lan requires --lan-relay <url> (e.g. http://192.168.1.50:8771)");
6823 }
6824 if with_uds && uds_socket.is_none() {
6826 bail!("--with-uds requires --uds-socket <path> (e.g. /tmp/wire.sock)");
6827 }
6828 let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
6829 let mut registry = crate::session::read_registry().unwrap_or_default();
6830 let name = match name_arg {
6831 Some(n) => crate::session::sanitize_name(n),
6832 None => crate::session::derive_name_from_cwd(&cwd, ®istry),
6833 };
6834 let session_home = crate::session::session_dir(&name)?;
6835
6836 let already_exists = session_home.exists()
6837 && session_home
6838 .join("config")
6839 .join("wire")
6840 .join("agent-card.json")
6841 .exists();
6842 if already_exists {
6843 registry
6847 .by_cwd
6848 .insert(cwd.to_string_lossy().into_owned(), name.clone());
6849 crate::session::write_registry(®istry)?;
6850 let info = render_session_info(&name, &session_home, &cwd)?;
6851 emit_session_new_result(&info, "already_exists", as_json)?;
6852 if !no_daemon {
6853 ensure_session_daemon(&session_home)?;
6854 }
6855 return Ok(());
6856 }
6857
6858 std::fs::create_dir_all(&session_home)
6859 .with_context(|| format!("creating session dir {session_home:?}"))?;
6860
6861 let init_args: Vec<&str> = if local_only {
6866 vec!["init", &name]
6867 } else {
6868 vec!["init", &name, "--relay", relay]
6869 };
6870 let init_status = run_wire_with_home(&session_home, &init_args)?;
6871 if !init_status.success() {
6872 let how = if local_only {
6873 format!("`wire init {name}` (local-only)")
6874 } else {
6875 format!("`wire init {name} --relay {relay}`")
6876 };
6877 bail!("{how} failed inside session dir {session_home:?}");
6878 }
6879
6880 let effective_handle = if local_only {
6885 name.clone()
6886 } else {
6887 let mut claim_attempt = 0u32;
6888 let mut effective = name.clone();
6889 loop {
6890 claim_attempt += 1;
6891 let status =
6892 run_wire_with_home(&session_home, &["claim", &effective, "--relay", relay])?;
6893 if status.success() {
6894 break;
6895 }
6896 if claim_attempt >= 5 {
6897 bail!(
6898 "5 failed attempts to claim a handle on {relay} for session {name}. \
6899 Try `wire session destroy {name} --force` and re-run with a different name, \
6900 or use `--local-only` if you don't need a federation address."
6901 );
6902 }
6903 let attempt_path = cwd.join(format!("__attempt_{claim_attempt}"));
6904 let suffix = crate::session::derive_name_from_cwd(&attempt_path, ®istry);
6905 let token = suffix
6906 .rsplit('-')
6907 .next()
6908 .filter(|t| t.len() == 4)
6909 .map(str::to_string)
6910 .unwrap_or_else(|| format!("{claim_attempt}"));
6911 effective = format!("{name}-{token}");
6912 }
6913 effective
6914 };
6915
6916 registry
6919 .by_cwd
6920 .insert(cwd.to_string_lossy().into_owned(), name.clone());
6921 crate::session::write_registry(®istry)?;
6922
6923 if with_local {
6934 try_allocate_local_slot(&session_home, &effective_handle, relay, local_relay);
6935 if local_only {
6936 let relay_state_path = session_home.join("config").join("wire").join("relay.json");
6941 let state: Value = std::fs::read(&relay_state_path)
6942 .ok()
6943 .and_then(|b| serde_json::from_slice(&b).ok())
6944 .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
6945 let endpoints = crate::endpoints::self_endpoints(&state);
6946 let has_local = endpoints
6947 .iter()
6948 .any(|e| e.scope == crate::endpoints::EndpointScope::Local);
6949 if !has_local {
6950 bail!(
6951 "--local-only requested but local-relay probe at {local_relay} failed — \
6952 ensure the local relay is running (`wire service install --local-relay`), \
6953 then re-run `wire session new {name} --local-only`."
6954 );
6955 }
6956 }
6957 }
6958
6959 if with_lan && let Some(lan_url) = lan_relay {
6963 try_allocate_lan_slot(&session_home, &effective_handle, lan_url);
6964 }
6965 if with_uds && let Some(socket_path) = uds_socket {
6967 try_allocate_uds_slot(&session_home, &effective_handle, socket_path);
6968 }
6969
6970 if !no_daemon {
6971 ensure_session_daemon(&session_home)?;
6972 }
6973
6974 let info = render_session_info(&name, &session_home, &cwd)?;
6975 emit_session_new_result(&info, "created", as_json)
6976}
6977
6978#[cfg(unix)]
6988fn try_allocate_uds_slot(
6989 session_home: &std::path::Path,
6990 handle: &str,
6991 uds_socket: &std::path::Path,
6992) {
6993 let healthz = match crate::relay_client::uds_request(uds_socket, "GET", "/healthz", &[], b"") {
6996 Ok((200, _)) => true,
6997 Ok((status, body)) => {
6998 eprintln!(
6999 "wire session new: UDS relay probe at {uds_socket:?} returned {status} ({}) — not publishing UDS endpoint",
7000 String::from_utf8_lossy(&body)
7001 );
7002 return;
7003 }
7004 Err(e) => {
7005 eprintln!(
7006 "wire session new: UDS relay at {uds_socket:?} unreachable ({e:#}) — \
7007 not publishing UDS endpoint. Start one with `wire relay-server --uds <path>`."
7008 );
7009 return;
7010 }
7011 };
7012 if !healthz {
7013 return;
7014 }
7015
7016 let alloc_body = serde_json::json!({"handle": handle}).to_string();
7018 let (status, body) = match crate::relay_client::uds_request(
7019 uds_socket,
7020 "POST",
7021 "/v1/slot/allocate",
7022 &[("Content-Type", "application/json")],
7023 alloc_body.as_bytes(),
7024 ) {
7025 Ok(r) => r,
7026 Err(e) => {
7027 eprintln!(
7028 "wire session new: UDS relay slot allocation request failed: {e:#} — not publishing UDS endpoint"
7029 );
7030 return;
7031 }
7032 };
7033 if status >= 300 {
7034 eprintln!(
7035 "wire session new: UDS relay slot allocation returned {status} ({}) — not publishing UDS endpoint",
7036 String::from_utf8_lossy(&body)
7037 );
7038 return;
7039 }
7040 let alloc: crate::relay_client::AllocateResponse = match serde_json::from_slice(&body) {
7041 Ok(a) => a,
7042 Err(e) => {
7043 eprintln!("wire session new: UDS relay returned unparseable allocate response: {e:#}");
7044 return;
7045 }
7046 };
7047
7048 let state_path = session_home.join("config").join("wire").join("relay.json");
7049 let mut state: serde_json::Value = std::fs::read(&state_path)
7050 .ok()
7051 .and_then(|b| serde_json::from_slice(&b).ok())
7052 .unwrap_or_else(|| serde_json::json!({}));
7053
7054 let mut endpoints: Vec<crate::endpoints::Endpoint> = state
7055 .get("self")
7056 .and_then(|s| s.get("endpoints"))
7057 .and_then(|e| e.as_array())
7058 .map(|arr| {
7059 arr.iter()
7060 .filter_map(|v| {
7061 serde_json::from_value::<crate::endpoints::Endpoint>(v.clone()).ok()
7062 })
7063 .collect()
7064 })
7065 .unwrap_or_default();
7066 endpoints.push(crate::endpoints::Endpoint::uds(
7067 format!("unix://{}", uds_socket.display()),
7068 alloc.slot_id.clone(),
7069 alloc.slot_token.clone(),
7070 ));
7071
7072 let self_obj = state
7073 .as_object_mut()
7074 .expect("relay_state root is an object")
7075 .entry("self")
7076 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
7077 if !self_obj.is_object() {
7078 *self_obj = serde_json::Value::Object(serde_json::Map::new());
7079 }
7080 if let Some(obj) = self_obj.as_object_mut() {
7081 obj.insert(
7082 "endpoints".into(),
7083 serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
7084 );
7085 }
7086 if let Err(e) = std::fs::write(
7087 &state_path,
7088 serde_json::to_vec_pretty(&state).expect("relay_state serializable"),
7089 ) {
7090 eprintln!("wire session new: failed to write {state_path:?}: {e}");
7091 return;
7092 }
7093 eprintln!(
7094 "wire session new: UDS slot allocated on unix://{} (slot_id={}) — sister sessions will see this endpoint in your agent-card",
7095 uds_socket.display(),
7096 alloc.slot_id
7097 );
7098}
7099
7100#[cfg(not(unix))]
7101fn try_allocate_uds_slot(
7102 _session_home: &std::path::Path,
7103 _handle: &str,
7104 _uds_socket: &std::path::Path,
7105) {
7106 eprintln!(
7107 "wire session new: --with-uds is Unix-only (Windows lacks AF_UNIX in tokio/reqwest); ignoring"
7108 );
7109}
7110
7111fn try_allocate_lan_slot(session_home: &std::path::Path, handle: &str, lan_relay: &str) {
7121 let probe = match crate::relay_client::build_blocking_client(Some(
7122 std::time::Duration::from_millis(500),
7123 )) {
7124 Ok(c) => c,
7125 Err(e) => {
7126 eprintln!("wire session new: cannot build LAN probe client for {lan_relay}: {e:#}");
7127 return;
7128 }
7129 };
7130 let healthz_url = format!("{}/healthz", lan_relay.trim_end_matches('/'));
7131 match probe.get(&healthz_url).send() {
7132 Ok(resp) if resp.status().is_success() => {}
7133 Ok(resp) => {
7134 eprintln!(
7135 "wire session new: LAN relay probe at {healthz_url} returned {} — not publishing LAN endpoint",
7136 resp.status()
7137 );
7138 return;
7139 }
7140 Err(e) => {
7141 eprintln!(
7142 "wire session new: LAN relay at {lan_relay} unreachable ({}) — not publishing LAN endpoint. \
7143 Start one on the LAN-bound interface with `wire relay-server --bind <LAN-IP>:8771 --local-only`.",
7144 crate::relay_client::format_transport_error(&anyhow::Error::new(e))
7145 );
7146 return;
7147 }
7148 };
7149
7150 let lan_client = crate::relay_client::RelayClient::new(lan_relay);
7151 let alloc = match lan_client.allocate_slot(Some(handle)) {
7152 Ok(a) => a,
7153 Err(e) => {
7154 eprintln!(
7155 "wire session new: LAN relay slot allocation failed: {e:#} — not publishing LAN endpoint"
7156 );
7157 return;
7158 }
7159 };
7160
7161 let state_path = session_home.join("config").join("wire").join("relay.json");
7162 let mut state: serde_json::Value = std::fs::read(&state_path)
7163 .ok()
7164 .and_then(|b| serde_json::from_slice(&b).ok())
7165 .unwrap_or_else(|| serde_json::json!({}));
7166
7167 let mut endpoints: Vec<crate::endpoints::Endpoint> = state
7170 .get("self")
7171 .and_then(|s| s.get("endpoints"))
7172 .and_then(|e| e.as_array())
7173 .map(|arr| {
7174 arr.iter()
7175 .filter_map(|v| {
7176 serde_json::from_value::<crate::endpoints::Endpoint>(v.clone()).ok()
7177 })
7178 .collect()
7179 })
7180 .unwrap_or_default();
7181 endpoints.push(crate::endpoints::Endpoint::lan(
7182 lan_relay.trim_end_matches('/').to_string(),
7183 alloc.slot_id.clone(),
7184 alloc.slot_token.clone(),
7185 ));
7186
7187 let self_obj = state
7188 .as_object_mut()
7189 .expect("relay_state root is an object")
7190 .entry("self")
7191 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
7192 if !self_obj.is_object() {
7193 *self_obj = serde_json::Value::Object(serde_json::Map::new());
7194 }
7195 if let Some(obj) = self_obj.as_object_mut() {
7196 obj.insert(
7197 "endpoints".into(),
7198 serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
7199 );
7200 }
7201 if let Err(e) = std::fs::write(
7202 &state_path,
7203 serde_json::to_vec_pretty(&state).expect("relay_state serializable"),
7204 ) {
7205 eprintln!("wire session new: failed to write {state_path:?}: {e}");
7206 return;
7207 }
7208 eprintln!(
7209 "wire session new: LAN slot allocated on {lan_relay} (slot_id={}) — peers will see this endpoint in your agent-card",
7210 alloc.slot_id
7211 );
7212}
7213
7214fn try_allocate_local_slot(
7222 session_home: &std::path::Path,
7223 handle: &str,
7224 _federation_relay: &str,
7225 local_relay: &str,
7226) {
7227 let probe = match crate::relay_client::build_blocking_client(Some(
7230 std::time::Duration::from_millis(500),
7231 )) {
7232 Ok(c) => c,
7233 Err(e) => {
7234 eprintln!("wire session new: cannot build probe client for {local_relay}: {e:#}");
7235 return;
7236 }
7237 };
7238 let healthz_url = format!("{}/healthz", local_relay.trim_end_matches('/'));
7239 match probe.get(&healthz_url).send() {
7240 Ok(resp) if resp.status().is_success() => {}
7241 Ok(resp) => {
7242 eprintln!(
7243 "wire session new: local relay probe at {healthz_url} returned {} — staying federation-only",
7244 resp.status()
7245 );
7246 return;
7247 }
7248 Err(e) => {
7249 eprintln!(
7250 "wire session new: local relay at {local_relay} unreachable ({}) — staying federation-only. \
7251 Start one with `wire relay-server --bind 127.0.0.1:8771 --local-only`.",
7252 crate::relay_client::format_transport_error(&anyhow::Error::new(e))
7253 );
7254 return;
7255 }
7256 };
7257
7258 let local_client = crate::relay_client::RelayClient::new(local_relay);
7260 let alloc = match local_client.allocate_slot(Some(handle)) {
7261 Ok(a) => a,
7262 Err(e) => {
7263 eprintln!(
7264 "wire session new: local relay slot allocation failed: {e:#} — staying federation-only"
7265 );
7266 return;
7267 }
7268 };
7269
7270 let state_path = session_home.join("config").join("wire").join("relay.json");
7285 let mut state: serde_json::Value = std::fs::read(&state_path)
7286 .ok()
7287 .and_then(|b| serde_json::from_slice(&b).ok())
7288 .unwrap_or_else(|| serde_json::json!({}));
7289 let fed_endpoint = state.get("self").and_then(|s| {
7292 let url = s.get("relay_url").and_then(serde_json::Value::as_str)?;
7293 let slot_id = s.get("slot_id").and_then(serde_json::Value::as_str)?;
7294 let slot_token = s.get("slot_token").and_then(serde_json::Value::as_str)?;
7295 Some(crate::endpoints::Endpoint::federation(
7296 url.to_string(),
7297 slot_id.to_string(),
7298 slot_token.to_string(),
7299 ))
7300 });
7301
7302 let local_endpoint = crate::endpoints::Endpoint::local(
7303 local_relay.trim_end_matches('/').to_string(),
7304 alloc.slot_id.clone(),
7305 alloc.slot_token.clone(),
7306 );
7307
7308 let mut endpoints: Vec<crate::endpoints::Endpoint> = Vec::new();
7309 if let Some(f) = fed_endpoint.clone() {
7310 endpoints.push(f);
7311 }
7312 endpoints.push(local_endpoint);
7313
7314 let (legacy_relay, legacy_slot_id, legacy_slot_token) = match fed_endpoint.clone() {
7324 Some(f) => (f.relay_url, f.slot_id, f.slot_token),
7325 None => (
7326 local_relay.trim_end_matches('/').to_string(),
7327 alloc.slot_id.clone(),
7328 alloc.slot_token.clone(),
7329 ),
7330 };
7331 let self_obj = state
7332 .as_object_mut()
7333 .expect("relay_state root is an object")
7334 .entry("self")
7335 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
7336 if !self_obj.is_object() {
7339 *self_obj = serde_json::Value::Object(serde_json::Map::new());
7340 }
7341 if let Some(obj) = self_obj.as_object_mut() {
7342 obj.insert("relay_url".into(), serde_json::Value::String(legacy_relay));
7343 obj.insert("slot_id".into(), serde_json::Value::String(legacy_slot_id));
7344 obj.insert(
7345 "slot_token".into(),
7346 serde_json::Value::String(legacy_slot_token),
7347 );
7348 obj.insert(
7349 "endpoints".into(),
7350 serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
7351 );
7352 }
7353
7354 if let Err(e) = std::fs::write(
7355 &state_path,
7356 serde_json::to_vec_pretty(&state).unwrap_or_default(),
7357 ) {
7358 eprintln!(
7359 "wire session new: persisting dual-slot relay_state at {state_path:?} failed: {e}"
7360 );
7361 return;
7362 }
7363 eprintln!(
7364 "wire session new: local slot allocated on {local_relay} (slot_id={})",
7365 alloc.slot_id
7366 );
7367}
7368
7369fn render_session_info(
7370 name: &str,
7371 session_home: &std::path::Path,
7372 cwd: &std::path::Path,
7373) -> Result<serde_json::Value> {
7374 let card_path = session_home
7375 .join("config")
7376 .join("wire")
7377 .join("agent-card.json");
7378 let (did, handle) = if card_path.exists() {
7379 let card: Value = serde_json::from_slice(&std::fs::read(&card_path)?)?;
7380 let did = card
7381 .get("did")
7382 .and_then(Value::as_str)
7383 .unwrap_or("")
7384 .to_string();
7385 let handle = card
7386 .get("handle")
7387 .and_then(Value::as_str)
7388 .map(str::to_string)
7389 .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
7390 (did, handle)
7391 } else {
7392 (String::new(), String::new())
7393 };
7394 Ok(json!({
7395 "name": name,
7396 "home_dir": session_home.to_string_lossy(),
7397 "cwd": cwd.to_string_lossy(),
7398 "did": did,
7399 "handle": handle,
7400 "export": format!("export WIRE_HOME={}", session_home.to_string_lossy()),
7401 }))
7402}
7403
7404fn emit_session_new_result(info: &serde_json::Value, status: &str, as_json: bool) -> Result<()> {
7405 if as_json {
7406 let mut obj = info.clone();
7407 obj["status"] = json!(status);
7408 println!("{}", serde_json::to_string(&obj)?);
7409 } else {
7410 let name = info["name"].as_str().unwrap_or("?");
7411 let handle = info["handle"].as_str().unwrap_or("?");
7412 let home = info["home_dir"].as_str().unwrap_or("?");
7413 let did = info["did"].as_str().unwrap_or("?");
7414 let export = info["export"].as_str().unwrap_or("?");
7415 let prefix = if status == "already_exists" {
7416 "session already exists (re-registered cwd)"
7417 } else {
7418 "session created"
7419 };
7420 println!(
7421 "{prefix}\n name: {name}\n handle: {handle}\n did: {did}\n home: {home}\n\nactivate with:\n {export}"
7422 );
7423 }
7424 Ok(())
7425}
7426
7427fn run_wire_with_home(
7428 session_home: &std::path::Path,
7429 args: &[&str],
7430) -> Result<std::process::ExitStatus> {
7431 let bin = std::env::current_exe().with_context(|| "locating self exe")?;
7432 let status = std::process::Command::new(&bin)
7433 .env("WIRE_HOME", session_home)
7434 .env_remove("RUST_LOG")
7435 .env("WIRE_AUTO_INIT", "0")
7438 .args(args)
7439 .status()
7440 .with_context(|| format!("spawning `wire {}`", args.join(" ")))?;
7441 Ok(status)
7442}
7443
7444pub fn maybe_auto_init_cwd_session(label: &str) {
7463 if std::env::var("WIRE_HOME").is_ok() {
7464 return; }
7466 if std::env::var("WIRE_AUTO_INIT").as_deref() == Ok("0") {
7467 return; }
7469 let cwd = match std::env::current_dir() {
7470 Ok(c) => c,
7471 Err(_) => return,
7472 };
7473 if crate::session::detect_session_wire_home(&cwd).is_some() {
7476 return;
7477 }
7478
7479 use fs2::FileExt;
7496 let sessions_root = match crate::session::sessions_root() {
7497 Ok(r) => r,
7498 Err(_) => return,
7499 };
7500 if let Err(e) = std::fs::create_dir_all(&sessions_root) {
7501 eprintln!("wire {label}: auto-init: failed to create sessions root {sessions_root:?}: {e}");
7502 return;
7503 }
7504 let lock_path = sessions_root.join(".auto-init.lock");
7505 let lock_file = match std::fs::OpenOptions::new()
7506 .create(true)
7507 .truncate(false)
7508 .read(true)
7509 .write(true)
7510 .open(&lock_path)
7511 {
7512 Ok(f) => f,
7513 Err(e) => {
7514 eprintln!(
7515 "wire {label}: auto-init: cannot open lockfile {lock_path:?}: {e} — falling back to default identity"
7516 );
7517 return;
7518 }
7519 };
7520 if let Err(e) = lock_file.lock_exclusive() {
7521 eprintln!(
7522 "wire {label}: auto-init: flock {lock_path:?} failed: {e} — falling back to default identity"
7523 );
7524 return;
7525 }
7526 let registry = crate::session::read_registry().unwrap_or_default();
7531 let name = crate::session::derive_name_from_cwd(&cwd, ®istry);
7532 let session_home = match crate::session::session_dir(&name) {
7533 Ok(h) => h,
7534 Err(_) => {
7535 let _ = fs2::FileExt::unlock(&lock_file);
7536 return;
7537 }
7538 };
7539 let agent_card_path = session_home
7540 .join("config")
7541 .join("wire")
7542 .join("agent-card.json");
7543 let needs_init = !agent_card_path.exists();
7544
7545 if needs_init {
7546 if let Err(e) = std::fs::create_dir_all(&session_home) {
7547 eprintln!(
7548 "wire {label}: auto-init: failed to create session dir {session_home:?}: {e}"
7549 );
7550 let _ = fs2::FileExt::unlock(&lock_file);
7551 return;
7552 }
7553 match run_wire_with_home(&session_home, &["init", &name]) {
7554 Ok(status) if status.success() => {}
7555 Ok(status) => {
7556 eprintln!(
7557 "wire {label}: auto-init: `wire init {name}` exited non-zero ({status}) — falling back to default identity"
7558 );
7559 let _ = fs2::FileExt::unlock(&lock_file);
7560 return;
7561 }
7562 Err(e) => {
7563 eprintln!(
7564 "wire {label}: auto-init: failed to spawn `wire init {name}`: {e:#} — falling back to default identity"
7565 );
7566 let _ = fs2::FileExt::unlock(&lock_file);
7567 return;
7568 }
7569 }
7570 try_allocate_local_slot(
7577 &session_home,
7578 &name,
7579 "https://wireup.net",
7580 "http://127.0.0.1:8771",
7581 );
7582 } else {
7583 if std::env::var("WIRE_QUIET_AUTOSESSION").is_err() {
7587 eprintln!(
7588 "wire {label}: auto-init: session `{name}` already exists (concurrent mcp peer won the race) — adopting"
7589 );
7590 }
7591 }
7592 let cwd_key = cwd.to_string_lossy().into_owned();
7602 let name_for_reg = name.clone();
7603 if let Err(e) = crate::session::update_registry(|reg| {
7604 reg.by_cwd.insert(cwd_key, name_for_reg);
7605 Ok(())
7606 }) {
7607 eprintln!("wire {label}: auto-init: failed to update registry: {e:#}");
7608 }
7610 let _ = fs2::FileExt::unlock(&lock_file);
7613
7614 if std::env::var("WIRE_QUIET_AUTOSESSION").is_err() {
7615 eprintln!(
7616 "wire {label}: auto-init: created session `{name}` for cwd `{}` → WIRE_HOME=`{}`",
7617 cwd.display(),
7618 session_home.display()
7619 );
7620 }
7621 unsafe {
7624 std::env::set_var("WIRE_HOME", &session_home);
7625 }
7626}
7627
7628fn ensure_session_daemon(session_home: &std::path::Path) -> Result<()> {
7629 let pidfile = session_home.join("state").join("wire").join("daemon.pid");
7632 if pidfile.exists() {
7633 let bytes = std::fs::read(&pidfile).unwrap_or_default();
7634 let pid: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
7635 v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
7636 } else {
7637 String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
7638 };
7639 if let Some(p) = pid {
7640 let alive = {
7641 #[cfg(target_os = "linux")]
7642 {
7643 std::path::Path::new(&format!("/proc/{p}")).exists()
7644 }
7645 #[cfg(not(target_os = "linux"))]
7646 {
7647 std::process::Command::new("kill")
7648 .args(["-0", &p.to_string()])
7649 .output()
7650 .map(|o| o.status.success())
7651 .unwrap_or(false)
7652 }
7653 };
7654 if alive {
7655 return Ok(());
7656 }
7657 }
7658 }
7659
7660 let bin = std::env::current_exe().with_context(|| "locating self exe")?;
7663 let log_path = session_home.join("state").join("wire").join("daemon.log");
7664 if let Some(parent) = log_path.parent() {
7665 std::fs::create_dir_all(parent).ok();
7666 }
7667 let log_file = std::fs::OpenOptions::new()
7668 .create(true)
7669 .append(true)
7670 .open(&log_path)
7671 .with_context(|| format!("opening daemon log {log_path:?}"))?;
7672 let log_err = log_file.try_clone()?;
7673 std::process::Command::new(&bin)
7674 .env("WIRE_HOME", session_home)
7675 .env_remove("RUST_LOG")
7676 .args(["daemon", "--interval", "5"])
7677 .stdout(log_file)
7678 .stderr(log_err)
7679 .stdin(std::process::Stdio::null())
7680 .spawn()
7681 .with_context(|| "spawning session-local `wire daemon`")?;
7682 Ok(())
7683}
7684
7685fn cmd_session_list(as_json: bool) -> Result<()> {
7686 let items = crate::session::list_sessions()?;
7687 if as_json {
7688 println!("{}", serde_json::to_string(&items)?);
7689 return Ok(());
7690 }
7691 if items.is_empty() {
7692 println!("no sessions on this machine. `wire session new` to create one.");
7693 return Ok(());
7694 }
7695 println!(
7696 "{:<22} {:<24} {:<24} {:<10} CWD",
7697 "CHARACTER", "NAME", "HANDLE", "DAEMON"
7698 );
7699 for s in items {
7700 let plain = s
7704 .character
7705 .as_ref()
7706 .map(|c| c.short())
7707 .unwrap_or_else(|| "?".to_string());
7708 let colored = s
7709 .character
7710 .as_ref()
7711 .map(|c| c.colored())
7712 .unwrap_or_else(|| "?".to_string());
7713 let displayed_width = plain.chars().count() + 1; let pad = 22usize.saturating_sub(displayed_width);
7718 println!(
7719 "{}{} {:<24} {:<24} {:<10} {}",
7720 colored,
7721 " ".repeat(pad),
7722 s.name,
7723 s.handle.as_deref().unwrap_or("?"),
7724 if s.daemon_running { "running" } else { "down" },
7725 s.cwd.as_deref().unwrap_or("(no cwd registered)"),
7726 );
7727 }
7728 Ok(())
7729}
7730
7731fn cmd_session_list_local(as_json: bool) -> Result<()> {
7743 let listing = crate::session::list_local_sessions()?;
7744 if as_json {
7745 println!("{}", serde_json::to_string(&listing)?);
7746 return Ok(());
7747 }
7748
7749 if listing.local.is_empty() && listing.federation_only.is_empty() {
7750 println!(
7751 "no sessions on this machine. `wire session new --with-local` to create one \
7752 with a local-relay endpoint (start the relay first: \
7753 `wire relay-server --bind 127.0.0.1:8771 --local-only`)."
7754 );
7755 return Ok(());
7756 }
7757
7758 if listing.local.is_empty() {
7759 println!(
7760 "no sister sessions reachable via a local relay. \
7761 Re-run `wire session new --with-local` to add a Local endpoint, or \
7762 start a local relay with `wire relay-server --bind 127.0.0.1:8771 --local-only`."
7763 );
7764 } else {
7765 let mut keys: Vec<&String> = listing.local.keys().collect();
7767 keys.sort();
7768 for relay_url in keys {
7769 let group = &listing.local[relay_url];
7770 println!("LOCAL RELAY: {relay_url}");
7771 println!(" {:<24} {:<32} {:<10} CWD", "NAME", "HANDLE", "DAEMON");
7772 for s in group {
7773 println!(
7774 " {:<24} {:<32} {:<10} {}",
7775 s.name,
7776 s.handle.as_deref().unwrap_or("?"),
7777 if s.daemon_running { "running" } else { "down" },
7778 s.cwd.as_deref().unwrap_or("(no cwd registered)"),
7779 );
7780 }
7781 println!();
7782 }
7783 }
7784
7785 if !listing.federation_only.is_empty() {
7786 println!("federation-only (no local endpoint):");
7787 for s in &listing.federation_only {
7788 println!(
7789 " {:<24} {:<32} {}",
7790 s.name,
7791 s.handle.as_deref().unwrap_or("?"),
7792 s.cwd.as_deref().unwrap_or("(no cwd registered)"),
7793 );
7794 }
7795 }
7796 Ok(())
7797}
7798
7799fn cmd_session_pair_all_local(
7818 settle_secs: u64,
7819 federation_relay: &str,
7820 as_json: bool,
7821) -> Result<()> {
7822 use std::collections::BTreeSet;
7823 use std::time::Duration;
7824
7825 let listing = crate::session::list_local_sessions()?;
7826 let mut by_name: std::collections::BTreeMap<String, crate::session::LocalSessionView> =
7830 Default::default();
7831 for group in listing.local.into_values() {
7832 for s in group {
7833 by_name.entry(s.name.clone()).or_insert(s);
7834 }
7835 }
7836 let sessions: Vec<crate::session::LocalSessionView> = by_name.into_values().collect();
7837
7838 if sessions.len() < 2 {
7839 let msg = format!(
7840 "{} sister session(s) with a local endpoint — need at least 2 to pair.",
7841 sessions.len()
7842 );
7843 if as_json {
7844 println!(
7845 "{}",
7846 serde_json::to_string(&json!({
7847 "sessions": sessions.iter().map(|s| &s.name).collect::<Vec<_>>(),
7848 "pairs_attempted": 0,
7849 "pairs_succeeded": 0,
7850 "pairs_skipped_already_paired": 0,
7851 "pairs_failed": 0,
7852 "note": msg,
7853 }))?
7854 );
7855 } else {
7856 println!("{msg}");
7857 if let Some(s) = sessions.first() {
7858 println!(" - {} ({})", s.name, s.cwd.as_deref().unwrap_or("?"));
7859 }
7860 println!("Use `wire session new --with-local` to add more.");
7861 }
7862 return Ok(());
7863 }
7864
7865 let fed_host = host_of_url(federation_relay);
7866 if fed_host.is_empty() {
7867 bail!(
7868 "federation_relay `{federation_relay}` has no parseable host — \
7869 pass a full URL like `https://wireup.net`."
7870 );
7871 }
7872
7873 let mut attempted = 0u32;
7875 let mut succeeded = 0u32;
7876 let mut skipped_already = 0u32;
7877 let mut failed = 0u32;
7878 let mut per_pair: Vec<Value> = Vec::new();
7879
7880 for i in 0..sessions.len() {
7881 for j in (i + 1)..sessions.len() {
7882 let a = &sessions[i];
7883 let b = &sessions[j];
7884 attempted += 1;
7885
7886 let a_pinned_b = session_has_peer(&a.home_dir, &b.name);
7889 let b_pinned_a = session_has_peer(&b.home_dir, &a.name);
7890 if a_pinned_b && b_pinned_a {
7891 skipped_already += 1;
7892 per_pair.push(json!({
7893 "from": a.name,
7894 "to": b.name,
7895 "status": "already_paired",
7896 }));
7897 continue;
7898 }
7899
7900 let pair_result = drive_bilateral_pair(
7901 &a.home_dir,
7902 &a.name,
7903 &b.home_dir,
7904 &b.name,
7905 &fed_host,
7906 federation_relay,
7907 settle_secs,
7908 );
7909
7910 match pair_result {
7911 Ok(()) => {
7912 succeeded += 1;
7913 per_pair.push(json!({
7914 "from": a.name,
7915 "to": b.name,
7916 "status": "paired",
7917 }));
7918 }
7919 Err(e) => {
7920 failed += 1;
7921 let detail = format!("{e:#}");
7922 per_pair.push(json!({
7923 "from": a.name,
7924 "to": b.name,
7925 "status": "failed",
7926 "error": detail,
7927 }));
7928 }
7929 }
7930
7931 std::thread::sleep(Duration::from_millis(200));
7934 }
7935 }
7936
7937 let _ = BTreeSet::<String>::new(); let summary = json!({
7939 "sessions": sessions.iter().map(|s| s.name.clone()).collect::<Vec<_>>(),
7940 "pairs_attempted": attempted,
7941 "pairs_succeeded": succeeded,
7942 "pairs_skipped_already_paired": skipped_already,
7943 "pairs_failed": failed,
7944 "results": per_pair,
7945 });
7946 if as_json {
7947 println!("{}", serde_json::to_string(&summary)?);
7948 } else {
7949 println!(
7950 "wire session pair-all-local: {} session(s), {} pair(s) attempted",
7951 sessions.len(),
7952 attempted
7953 );
7954 println!(" paired: {succeeded}");
7955 println!(" skipped (already pinned): {skipped_already}");
7956 println!(" failed: {failed}");
7957 for entry in summary["results"].as_array().unwrap_or(&vec![]) {
7958 let from = entry["from"].as_str().unwrap_or("?");
7959 let to = entry["to"].as_str().unwrap_or("?");
7960 let status = entry["status"].as_str().unwrap_or("?");
7961 let err = entry.get("error").and_then(Value::as_str).unwrap_or("");
7962 if err.is_empty() {
7963 println!(" {from:<24} ↔ {to:<24} {status}");
7964 } else {
7965 println!(" {from:<24} ↔ {to:<24} {status} — {err}");
7966 }
7967 }
7968 }
7969 Ok(())
7970}
7971
7972fn session_has_peer(session_home: &std::path::Path, peer_name: &str) -> bool {
7975 val_session_relay_state(session_home)
7976 .and_then(|v| v.get("peers").cloned())
7977 .and_then(|p| p.get(peer_name).cloned())
7978 .is_some()
7979}
7980
7981fn val_session_relay_state(session_home: &std::path::Path) -> Option<Value> {
7986 let path = session_home.join("config").join("wire").join("relay.json");
7987 let bytes = std::fs::read(&path).ok()?;
7988 serde_json::from_slice(&bytes).ok()
7989}
7990
7991fn cmd_session_mesh_status(stale_secs: u64, as_json: bool) -> Result<()> {
7995 use std::collections::BTreeMap;
7996
7997 let listing = crate::session::list_local_sessions()?;
8000 let mut by_name: BTreeMap<String, crate::session::LocalSessionView> = BTreeMap::new();
8001 for group in listing.local.into_values() {
8002 for s in group {
8003 by_name.entry(s.name.clone()).or_insert(s);
8004 }
8005 }
8006 let sessions: Vec<crate::session::LocalSessionView> = by_name.into_values().collect();
8007 let federation_only = listing.federation_only;
8008
8009 if sessions.is_empty() {
8010 let msg = "no sister sessions with a local endpoint on this machine.".to_string();
8011 if as_json {
8012 println!(
8013 "{}",
8014 serde_json::to_string(&json!({
8015 "sessions": [],
8016 "edges": [],
8017 "local_relay": null,
8018 "federation_only": federation_only.iter().map(|f| &f.name).collect::<Vec<_>>(),
8019 "summary": {
8020 "session_count": 0,
8021 "edge_count": 0,
8022 "healthy": 0,
8023 "stale": 0,
8024 "asymmetric": 0,
8025 },
8026 "note": msg,
8027 }))?
8028 );
8029 } else {
8030 println!("{msg}");
8031 println!("Use `wire session new --with-local` to create one.");
8032 }
8033 return Ok(());
8034 }
8035
8036 struct SessionState {
8038 view: crate::session::LocalSessionView,
8039 relay_state: Value,
8040 local_relay_url: Option<String>,
8041 }
8042 let mut sstates: Vec<SessionState> = Vec::with_capacity(sessions.len());
8043 for s in sessions {
8044 let relay_state = val_session_relay_state(&s.home_dir)
8045 .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
8046 let local_relay_url = s.local_endpoints.first().map(|e| e.relay_url.clone());
8047 sstates.push(SessionState {
8048 view: s,
8049 relay_state,
8050 local_relay_url,
8051 });
8052 }
8053
8054 let mut local_relays: BTreeMap<String, bool> = BTreeMap::new();
8057 for s in &sstates {
8058 if let Some(url) = &s.local_relay_url
8059 && !local_relays.contains_key(url)
8060 {
8061 let healthy = probe_relay_healthz(url);
8062 local_relays.insert(url.clone(), healthy);
8063 }
8064 }
8065
8066 let now = std::time::SystemTime::now()
8067 .duration_since(std::time::UNIX_EPOCH)
8068 .map(|d| d.as_secs())
8069 .unwrap_or(0);
8070
8071 let mut edges: Vec<Value> = Vec::new();
8075 let mut healthy_count = 0u32;
8076 let mut stale_count = 0u32;
8077 let mut asymmetric_count = 0u32;
8078
8079 for i in 0..sstates.len() {
8080 for j in (i + 1)..sstates.len() {
8081 let a = &sstates[i];
8082 let b = &sstates[j];
8083 let a_to_b = probe_directed_edge(&a.relay_state, &b.view.name, now);
8084 let b_to_a = probe_directed_edge(&b.relay_state, &a.view.name, now);
8085
8086 let bilateral = a_to_b.pinned && b_to_a.pinned;
8087 let scope = match (a_to_b.scope.as_deref(), b_to_a.scope.as_deref()) {
8091 (Some("local"), _) | (_, Some("local")) => "local",
8092 (Some("federation"), _) | (_, Some("federation")) => "federation",
8093 _ => "unknown",
8094 };
8095
8096 let mut status = if bilateral { "healthy" } else { "asymmetric" };
8099 if bilateral {
8100 let either_stale = [&a_to_b, &b_to_a].iter().any(|d| match d.silent_secs {
8101 Some(s) => s > stale_secs,
8102 None => d.probed,
8103 });
8104 if either_stale {
8105 status = "stale";
8106 }
8107 }
8108
8109 match status {
8110 "healthy" => healthy_count += 1,
8111 "stale" => stale_count += 1,
8112 "asymmetric" => asymmetric_count += 1,
8113 _ => {}
8114 }
8115
8116 edges.push(json!({
8117 "from": a.view.name,
8118 "to": b.view.name,
8119 "bilateral": bilateral,
8120 "scope": scope,
8121 "status": status,
8122 "directions": {
8123 a.view.name.clone(): direction_summary(&a_to_b),
8124 b.view.name.clone(): direction_summary(&b_to_a),
8125 },
8126 }));
8127 }
8128 }
8129
8130 let summary = json!({
8131 "sessions": sstates.iter().map(|s| json!({
8132 "name": s.view.name,
8133 "handle": s.view.handle,
8134 "cwd": s.view.cwd,
8135 "daemon_running": s.view.daemon_running,
8136 "local_relay": s.local_relay_url,
8137 })).collect::<Vec<_>>(),
8138 "edges": edges,
8139 "local_relays": local_relays.iter().map(|(url, healthy)| json!({
8140 "url": url,
8141 "healthy": healthy,
8142 })).collect::<Vec<_>>(),
8143 "federation_only": federation_only.iter().map(|f| &f.name).collect::<Vec<_>>(),
8144 "summary": {
8145 "session_count": sstates.len(),
8146 "edge_count": edges.len(),
8147 "healthy": healthy_count,
8148 "stale": stale_count,
8149 "asymmetric": asymmetric_count,
8150 "stale_threshold_secs": stale_secs,
8151 },
8152 });
8153
8154 if as_json {
8155 println!("{}", serde_json::to_string(&summary)?);
8156 return Ok(());
8157 }
8158
8159 println!(
8160 "wire mesh: {} session(s), {} edge(s)",
8161 sstates.len(),
8162 edges.len()
8163 );
8164 for (url, healthy) in &local_relays {
8165 let tick = if *healthy { "✓" } else { "✗" };
8166 println!(" local-relay {url} {tick}");
8167 }
8168 if !federation_only.is_empty() {
8169 print!(" federation-only sessions:");
8170 for f in &federation_only {
8171 print!(" {}", f.name);
8172 }
8173 println!();
8174 }
8175
8176 let names: Vec<&str> = sstates.iter().map(|s| s.view.name.as_str()).collect();
8178 let col_w = names.iter().map(|n| n.len()).max().unwrap_or(8).max(7) + 1;
8179 print!("\n{:>col_w$}", "", col_w = col_w);
8180 for n in &names {
8181 print!("{:>col_w$}", n, col_w = col_w);
8182 }
8183 println!();
8184 for (i, row) in names.iter().enumerate() {
8185 print!("{:>col_w$}", row, col_w = col_w);
8186 for (j, col) in names.iter().enumerate() {
8187 let cell = if i == j {
8188 "self".to_string()
8189 } else {
8190 let d = probe_directed_edge(&sstates[i].relay_state, col, now);
8191 match d.scope.as_deref() {
8192 Some("local") => "local".to_string(),
8193 Some("federation") => "fed".to_string(),
8194 _ => "—".to_string(),
8195 }
8196 };
8197 print!("{:>col_w$}", cell, col_w = col_w);
8198 }
8199 println!();
8200 }
8201
8202 println!("\nHealth (stale threshold: {stale_secs}s):");
8203 for e in &edges {
8204 let from = e["from"].as_str().unwrap_or("?");
8205 let to = e["to"].as_str().unwrap_or("?");
8206 let scope = e["scope"].as_str().unwrap_or("?");
8207 let status = e["status"].as_str().unwrap_or("?");
8208 let mark = match status {
8209 "healthy" => "✓",
8210 "stale" => "⚠",
8211 "asymmetric" => "!",
8212 _ => "?",
8213 };
8214 let dirs = e["directions"].as_object().cloned().unwrap_or_default();
8215 let mut details: Vec<String> = Vec::new();
8216 for (who, d) in &dirs {
8217 let silent = d.get("silent_secs").and_then(Value::as_u64);
8218 let pinned = d.get("pinned").and_then(Value::as_bool).unwrap_or(false);
8219 let probed = d.get("probed").and_then(Value::as_bool).unwrap_or(false);
8220 let label = match (pinned, probed, silent) {
8221 (false, _, _) => format!("{who} has not pinned"),
8222 (true, false, _) => format!("{who} pinned but no endpoint to probe"),
8223 (true, true, Some(s)) if s <= stale_secs => format!("{who} fresh ({s}s)"),
8224 (true, true, Some(s)) => format!("{who} silent {s}s"),
8225 (true, true, None) => format!("{who} never pulled"),
8226 };
8227 details.push(label);
8228 }
8229 println!(
8230 " {mark} {from} ↔ {to} scope={scope} {status:>10} [{}]",
8231 details.join(" | ")
8232 );
8233 }
8234 Ok(())
8235}
8236
8237#[derive(Default)]
8238struct DirectedEdge {
8239 pinned: bool,
8240 scope: Option<String>,
8241 last_pull_at_unix: Option<u64>,
8242 silent_secs: Option<u64>,
8243 probed: bool,
8244 event_count: usize,
8245}
8246
8247fn probe_directed_edge(from_state: &Value, to_name: &str, now: u64) -> DirectedEdge {
8253 let pinned = from_state
8254 .get("peers")
8255 .and_then(|p| p.get(to_name))
8256 .is_some();
8257 if !pinned {
8258 return DirectedEdge::default();
8259 }
8260 let endpoints = crate::endpoints::peer_endpoints_in_priority_order(from_state, to_name);
8261 let ep = match endpoints.into_iter().next() {
8262 Some(e) => e,
8263 None => {
8264 return DirectedEdge {
8265 pinned: true,
8266 ..Default::default()
8267 };
8268 }
8269 };
8270 let scope = Some(
8271 match ep.scope {
8272 crate::endpoints::EndpointScope::Local => "local",
8273 crate::endpoints::EndpointScope::Lan => "lan",
8274 crate::endpoints::EndpointScope::Uds => "uds",
8275 crate::endpoints::EndpointScope::Federation => "federation",
8276 }
8277 .to_string(),
8278 );
8279 let client = crate::relay_client::RelayClient::new(&ep.relay_url);
8280 let (count, last) = client
8281 .slot_state(&ep.slot_id, &ep.slot_token)
8282 .unwrap_or((0, None));
8283 let silent = last.map(|t| now.saturating_sub(t));
8284 DirectedEdge {
8285 pinned: true,
8286 scope,
8287 last_pull_at_unix: last,
8288 silent_secs: silent,
8289 probed: true,
8290 event_count: count,
8291 }
8292}
8293
8294fn direction_summary(d: &DirectedEdge) -> Value {
8295 json!({
8296 "pinned": d.pinned,
8297 "scope": d.scope,
8298 "probed": d.probed,
8299 "last_pull_at_unix": d.last_pull_at_unix,
8300 "silent_secs": d.silent_secs,
8301 "event_count": d.event_count,
8302 })
8303}
8304
8305fn probe_relay_healthz(url: &str) -> bool {
8307 let probe_url = format!("{}/healthz", url.trim_end_matches('/'));
8308 let client = match reqwest::blocking::Client::builder()
8309 .timeout(std::time::Duration::from_millis(500))
8310 .build()
8311 {
8312 Ok(c) => c,
8313 Err(_) => return false,
8314 };
8315 match client.get(&probe_url).send() {
8316 Ok(r) => r.status().is_success(),
8317 Err(_) => false,
8318 }
8319}
8320
8321fn drive_bilateral_pair(
8336 a_home: &std::path::Path,
8337 a_name: &str,
8338 b_home: &std::path::Path,
8339 b_name: &str,
8340 _fed_host: &str,
8341 _federation_relay: &str,
8342 settle_secs: u64,
8343) -> Result<()> {
8344 use std::time::Duration;
8345 let bin = std::env::current_exe().context("locating self exe")?;
8346
8347 let run = |home: &std::path::Path, args: &[&str]| -> Result<()> {
8348 let out = std::process::Command::new(&bin)
8349 .env("WIRE_HOME", home)
8350 .env_remove("RUST_LOG")
8351 .args(args)
8352 .output()
8353 .with_context(|| format!("spawning `wire {}`", args.join(" ")))?;
8354 if !out.status.success() {
8355 bail!(
8356 "`wire {}` failed: stderr={}",
8357 args.join(" "),
8358 String::from_utf8_lossy(&out.stderr).trim()
8359 );
8360 }
8361 Ok(())
8362 };
8363
8364 run(a_home, &["add", b_name, "--local-sister", "--json"])
8370 .with_context(|| format!("step 1/8: {a_name} `wire add {b_name} --local-sister`"))?;
8371
8372 std::thread::sleep(Duration::from_secs(settle_secs));
8374
8375 run(b_home, &["pull", "--json"]).with_context(|| format!("step 4/8: {b_name} `wire pull`"))?;
8377 run(b_home, &["pair-accept", a_name, "--json"])
8378 .with_context(|| format!("step 5/8: {b_name} `wire pair-accept {a_name}`"))?;
8379 run(b_home, &["push", "--json"]).with_context(|| format!("step 6/8: {b_name} `wire push`"))?;
8380
8381 std::thread::sleep(Duration::from_secs(settle_secs));
8383
8384 run(a_home, &["pull", "--json"]).with_context(|| format!("step 8/8: {a_name} `wire pull`"))?;
8386
8387 Ok(())
8388}
8389
8390fn cmd_session_env(name_arg: Option<&str>, as_json: bool) -> Result<()> {
8391 let name = resolve_session_name(name_arg)?;
8392 let session_home = crate::session::session_dir(&name)?;
8393 if !session_home.exists() {
8394 bail!(
8395 "no session named {name:?} on this machine. `wire session list` to enumerate, \
8396 `wire session new {name}` to create."
8397 );
8398 }
8399 if as_json {
8400 println!(
8401 "{}",
8402 serde_json::to_string(&json!({
8403 "name": name,
8404 "home_dir": session_home.to_string_lossy(),
8405 "export": format!("export WIRE_HOME={}", session_home.to_string_lossy()),
8406 }))?
8407 );
8408 } else {
8409 println!("export WIRE_HOME={}", session_home.to_string_lossy());
8410 }
8411 Ok(())
8412}
8413
8414fn cmd_session_current(as_json: bool) -> Result<()> {
8415 let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
8416 let registry = crate::session::read_registry().unwrap_or_default();
8417 let cwd_key = cwd.to_string_lossy().into_owned();
8418 let name = registry.by_cwd.get(&cwd_key).cloned();
8419 if as_json {
8420 println!(
8421 "{}",
8422 serde_json::to_string(&json!({
8423 "cwd": cwd_key,
8424 "session": name,
8425 }))?
8426 );
8427 } else if let Some(n) = name {
8428 println!("{n}");
8429 } else {
8430 println!("(no session registered for this cwd)");
8431 }
8432 Ok(())
8433}
8434
8435fn cmd_session_destroy(name_arg: &str, force: bool, as_json: bool) -> Result<()> {
8436 let name = crate::session::sanitize_name(name_arg);
8437 let session_home = crate::session::session_dir(&name)?;
8438 if !session_home.exists() {
8439 if as_json {
8440 println!(
8441 "{}",
8442 serde_json::to_string(&json!({
8443 "name": name,
8444 "destroyed": false,
8445 "reason": "no such session",
8446 }))?
8447 );
8448 } else {
8449 println!("no session named {name:?} — nothing to destroy.");
8450 }
8451 return Ok(());
8452 }
8453 if !force {
8454 bail!(
8455 "destroying session {name:?} would delete its keypair + state irrecoverably. \
8456 Pass --force to confirm."
8457 );
8458 }
8459
8460 let pidfile = session_home.join("state").join("wire").join("daemon.pid");
8462 if let Ok(bytes) = std::fs::read(&pidfile) {
8463 let pid: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
8464 v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
8465 } else {
8466 String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
8467 };
8468 if let Some(p) = pid {
8469 let _ = std::process::Command::new("kill")
8470 .args(["-TERM", &p.to_string()])
8471 .output();
8472 }
8473 }
8474
8475 std::fs::remove_dir_all(&session_home)
8476 .with_context(|| format!("removing session dir {session_home:?}"))?;
8477
8478 let mut registry = crate::session::read_registry().unwrap_or_default();
8480 registry.by_cwd.retain(|_, v| v != &name);
8481 crate::session::write_registry(®istry)?;
8482
8483 if as_json {
8484 println!(
8485 "{}",
8486 serde_json::to_string(&json!({
8487 "name": name,
8488 "destroyed": true,
8489 }))?
8490 );
8491 } else {
8492 println!("destroyed session {name:?}.");
8493 }
8494 Ok(())
8495}
8496
8497fn cmd_diag(action: DiagAction) -> Result<()> {
8500 let state = config::state_dir()?;
8501 let knob = state.join("diag.enabled");
8502 let log_path = state.join("diag.jsonl");
8503 match action {
8504 DiagAction::Tail { limit, json } => {
8505 let entries = crate::diag::tail(limit);
8506 if json {
8507 for e in entries {
8508 println!("{}", serde_json::to_string(&e)?);
8509 }
8510 } else if entries.is_empty() {
8511 println!("wire diag: no entries (diag may be disabled — `wire diag enable`)");
8512 } else {
8513 for e in entries {
8514 let ts = e["ts"].as_u64().unwrap_or(0);
8515 let ty = e["type"].as_str().unwrap_or("?");
8516 let pid = e["pid"].as_u64().unwrap_or(0);
8517 let payload = e["payload"].to_string();
8518 println!("[{ts}] pid={pid} {ty} {payload}");
8519 }
8520 }
8521 }
8522 DiagAction::Enable => {
8523 config::ensure_dirs()?;
8524 std::fs::write(&knob, "1")?;
8525 println!("wire diag: enabled at {knob:?}");
8526 }
8527 DiagAction::Disable => {
8528 if knob.exists() {
8529 std::fs::remove_file(&knob)?;
8530 }
8531 println!("wire diag: disabled (env WIRE_DIAG may still flip it on per-process)");
8532 }
8533 DiagAction::Status { json } => {
8534 let enabled = crate::diag::is_enabled();
8535 let size = std::fs::metadata(&log_path).map(|m| m.len()).unwrap_or(0);
8536 if json {
8537 println!(
8538 "{}",
8539 serde_json::to_string(&serde_json::json!({
8540 "enabled": enabled,
8541 "log_path": log_path,
8542 "log_size_bytes": size,
8543 }))?
8544 );
8545 } else {
8546 println!("wire diag status");
8547 println!(" enabled: {enabled}");
8548 println!(" log: {log_path:?}");
8549 println!(" log size: {size} bytes");
8550 }
8551 }
8552 }
8553 Ok(())
8554}
8555
8556fn cmd_service(action: ServiceAction) -> Result<()> {
8559 let kind = |local_relay: bool| {
8560 if local_relay {
8561 crate::service::ServiceKind::LocalRelay
8562 } else {
8563 crate::service::ServiceKind::Daemon
8564 }
8565 };
8566 let (report, as_json) = match action {
8567 ServiceAction::Install { local_relay, json } => {
8568 (crate::service::install_kind(kind(local_relay))?, json)
8569 }
8570 ServiceAction::Uninstall { local_relay, json } => {
8571 (crate::service::uninstall_kind(kind(local_relay))?, json)
8572 }
8573 ServiceAction::Status { local_relay, json } => {
8574 (crate::service::status_kind(kind(local_relay))?, json)
8575 }
8576 };
8577 if as_json {
8578 println!("{}", serde_json::to_string(&report)?);
8579 } else {
8580 println!("wire service {}", report.action);
8581 println!(" platform: {}", report.platform);
8582 println!(" unit: {}", report.unit_path);
8583 println!(" status: {}", report.status);
8584 println!(" detail: {}", report.detail);
8585 }
8586 Ok(())
8587}
8588
8589fn cmd_upgrade(check_only: bool, as_json: bool) -> Result<()> {
8604 let pgrep_out = std::process::Command::new("pgrep")
8606 .args(["-f", "wire daemon"])
8607 .output();
8608 let running_pids: Vec<u32> = match pgrep_out {
8609 Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
8610 .split_whitespace()
8611 .filter_map(|s| s.parse::<u32>().ok())
8612 .collect(),
8613 _ => Vec::new(),
8614 };
8615
8616 let record = crate::ensure_up::read_pid_record("daemon");
8618 let recorded_version: Option<String> = match &record {
8619 crate::ensure_up::PidRecord::Json(d) => Some(d.version.clone()),
8620 crate::ensure_up::PidRecord::LegacyInt(_) => Some("<pre-0.5.11>".to_string()),
8621 _ => None,
8622 };
8623 let cli_version = env!("CARGO_PKG_VERSION").to_string();
8624
8625 let sessions_to_respawn_after_kill: Vec<std::path::PathBuf> = crate::session::list_sessions()
8632 .unwrap_or_default()
8633 .into_iter()
8634 .filter(|s| s.daemon_running)
8635 .map(|s| s.home_dir)
8636 .collect();
8637
8638 if check_only {
8639 let sessions_with_daemons: Vec<String> = crate::session::list_sessions()
8641 .unwrap_or_default()
8642 .iter()
8643 .filter(|s| s.daemon_running)
8644 .map(|s| s.name.clone())
8645 .collect();
8646 let mut path_dupes: Vec<String> = Vec::new();
8647 if let Ok(path) = std::env::var("PATH") {
8648 let mut seen: std::collections::HashSet<std::path::PathBuf> =
8649 std::collections::HashSet::new();
8650 for dir in path.split(':') {
8651 let candidate = std::path::PathBuf::from(dir).join("wire");
8652 if candidate.exists() {
8653 let canon = candidate.canonicalize().unwrap_or(candidate);
8654 if seen.insert(canon.clone()) {
8655 path_dupes.push(canon.to_string_lossy().into_owned());
8656 }
8657 }
8658 }
8659 }
8660 let report = json!({
8661 "running_pids": running_pids,
8662 "pidfile_version": recorded_version,
8663 "cli_version": cli_version,
8664 "would_kill": running_pids,
8665 "session_daemons_running": sessions_with_daemons,
8666 "path_binaries": path_dupes,
8667 "path_duplicate_warning": path_dupes.len() > 1,
8668 });
8669 if as_json {
8670 println!("{}", serde_json::to_string(&report)?);
8671 } else {
8672 println!("wire upgrade --check");
8673 println!(" cli version: {cli_version}");
8674 println!(
8675 " pidfile version: {}",
8676 recorded_version.as_deref().unwrap_or("(missing)")
8677 );
8678 if running_pids.is_empty() {
8679 println!(" running daemons: none");
8680 } else {
8681 let pids: Vec<String> = running_pids.iter().map(|p| p.to_string()).collect();
8682 println!(" running daemons: pids {}", pids.join(", "));
8683 println!(" would kill all + spawn fresh");
8684 }
8685 if !sessions_with_daemons.is_empty() {
8686 println!(
8687 " session daemons: {} (would respawn under new binary)",
8688 sessions_with_daemons.join(", ")
8689 );
8690 }
8691 if path_dupes.len() > 1 {
8692 println!(
8693 " PATH warning: {} distinct `wire` binaries on PATH:",
8694 path_dupes.len()
8695 );
8696 for b in &path_dupes {
8697 println!(" {b}");
8698 }
8699 println!(" operators should remove the stale ones");
8700 }
8701 }
8702 return Ok(());
8703 }
8704
8705 let mut killed: Vec<u32> = Vec::new();
8708 for pid in &running_pids {
8709 let _ = std::process::Command::new("kill")
8711 .args(["-15", &pid.to_string()])
8712 .status();
8713 killed.push(*pid);
8714 }
8715 if !killed.is_empty() {
8717 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
8718 loop {
8719 let still_alive: Vec<u32> = killed
8720 .iter()
8721 .copied()
8722 .filter(|p| process_alive_pid(*p))
8723 .collect();
8724 if still_alive.is_empty() {
8725 break;
8726 }
8727 if std::time::Instant::now() >= deadline {
8728 for pid in still_alive {
8730 let _ = std::process::Command::new("kill")
8731 .args(["-9", &pid.to_string()])
8732 .status();
8733 }
8734 break;
8735 }
8736 std::thread::sleep(std::time::Duration::from_millis(50));
8737 }
8738 }
8739
8740 let pidfile = config::state_dir()?.join("daemon.pid");
8743 if pidfile.exists() {
8744 let _ = std::fs::remove_file(&pidfile);
8745 }
8746
8747 if let Ok(sessions) = crate::session::list_sessions() {
8754 for s in &sessions {
8755 let session_pidfile = s.home_dir.join("state").join("wire").join("daemon.pid");
8756 if session_pidfile.exists() {
8757 let _ = std::fs::remove_file(&session_pidfile);
8758 }
8759 }
8760 }
8761 let session_daemons_to_respawn = sessions_to_respawn_after_kill;
8762
8763 let mut path_dupes: Vec<String> = Vec::new();
8768 if let Ok(path) = std::env::var("PATH") {
8769 let mut seen: std::collections::HashSet<std::path::PathBuf> =
8770 std::collections::HashSet::new();
8771 for dir in path.split(':') {
8772 let candidate = std::path::PathBuf::from(dir).join("wire");
8773 if candidate.exists() {
8774 let canon = candidate.canonicalize().unwrap_or(candidate);
8775 if seen.insert(canon.clone()) {
8776 path_dupes.push(canon.to_string_lossy().into_owned());
8777 }
8778 }
8779 }
8780 }
8781 let path_warning = if path_dupes.len() > 1 {
8782 Some(format!(
8783 "WARN: {} distinct `wire` binaries on PATH — old versions can shadow the fresh install:\n {}",
8784 path_dupes.len(),
8785 path_dupes.join("\n ")
8786 ))
8787 } else {
8788 None
8789 };
8790
8791 let spawned = crate::ensure_up::ensure_daemon_running()?;
8794
8795 let mut session_respawns: Vec<Value> = Vec::new();
8801 for home in &session_daemons_to_respawn {
8802 match ensure_session_daemon(home) {
8803 Ok(()) => session_respawns.push(json!({
8804 "session_home": home.to_string_lossy(),
8805 "status": "respawned",
8806 })),
8807 Err(e) => session_respawns.push(json!({
8808 "session_home": home.to_string_lossy(),
8809 "status": "failed",
8810 "error": format!("{e:#}"),
8811 })),
8812 }
8813 }
8814
8815 let new_record = crate::ensure_up::read_pid_record("daemon");
8816 let new_pid = new_record.pid();
8817 let new_version: Option<String> = if let crate::ensure_up::PidRecord::Json(d) = &new_record {
8818 Some(d.version.clone())
8819 } else {
8820 None
8821 };
8822
8823 if as_json {
8824 println!(
8825 "{}",
8826 serde_json::to_string(&json!({
8827 "killed": killed,
8828 "spawned_fresh_daemon": spawned,
8829 "new_pid": new_pid,
8830 "new_version": new_version,
8831 "cli_version": cli_version,
8832 "session_respawns": session_respawns,
8833 "path_binaries": path_dupes,
8834 "path_warning": path_warning,
8835 }))?
8836 );
8837 } else {
8838 if killed.is_empty() {
8839 println!("wire upgrade: no stale daemons running");
8840 } else {
8841 println!(
8842 "wire upgrade: killed {} daemon(s) (pids {})",
8843 killed.len(),
8844 killed
8845 .iter()
8846 .map(|p| p.to_string())
8847 .collect::<Vec<_>>()
8848 .join(", ")
8849 );
8850 }
8851 if spawned {
8852 println!(
8853 "wire upgrade: spawned fresh daemon (pid {} v{})",
8854 new_pid
8855 .map(|p| p.to_string())
8856 .unwrap_or_else(|| "?".to_string()),
8857 new_version.as_deref().unwrap_or(&cli_version),
8858 );
8859 } else {
8860 println!("wire upgrade: daemon was already running on current binary");
8861 }
8862 if !session_respawns.is_empty() {
8863 println!(
8864 "wire upgrade: refreshed {} session daemon(s):",
8865 session_respawns.len()
8866 );
8867 for r in &session_respawns {
8868 let h = r["session_home"].as_str().unwrap_or("?");
8869 let s = r["status"].as_str().unwrap_or("?");
8870 let label = std::path::Path::new(h)
8871 .file_name()
8872 .map(|f| f.to_string_lossy().into_owned())
8873 .unwrap_or_else(|| h.to_string());
8874 println!(" {label:<24} {s}");
8875 }
8876 }
8877 if let Some(msg) = &path_warning {
8878 eprintln!("wire upgrade: {msg}");
8879 }
8880 }
8881 Ok(())
8882}
8883
8884fn process_alive_pid(pid: u32) -> bool {
8885 #[cfg(target_os = "linux")]
8886 {
8887 std::path::Path::new(&format!("/proc/{pid}")).exists()
8888 }
8889 #[cfg(not(target_os = "linux"))]
8890 {
8891 std::process::Command::new("kill")
8892 .args(["-0", &pid.to_string()])
8893 .stdin(std::process::Stdio::null())
8894 .stdout(std::process::Stdio::null())
8895 .stderr(std::process::Stdio::null())
8896 .status()
8897 .map(|s| s.success())
8898 .unwrap_or(false)
8899 }
8900}
8901
8902#[derive(Clone, Debug, serde::Serialize)]
8906pub struct DoctorCheck {
8907 pub id: String,
8910 pub status: String,
8912 pub detail: String,
8914 #[serde(skip_serializing_if = "Option::is_none")]
8916 pub fix: Option<String>,
8917}
8918
8919impl DoctorCheck {
8920 fn pass(id: &str, detail: impl Into<String>) -> Self {
8921 Self {
8922 id: id.into(),
8923 status: "PASS".into(),
8924 detail: detail.into(),
8925 fix: None,
8926 }
8927 }
8928 fn warn(id: &str, detail: impl Into<String>, fix: impl Into<String>) -> Self {
8929 Self {
8930 id: id.into(),
8931 status: "WARN".into(),
8932 detail: detail.into(),
8933 fix: Some(fix.into()),
8934 }
8935 }
8936 fn fail(id: &str, detail: impl Into<String>, fix: impl Into<String>) -> Self {
8937 Self {
8938 id: id.into(),
8939 status: "FAIL".into(),
8940 detail: detail.into(),
8941 fix: Some(fix.into()),
8942 }
8943 }
8944}
8945
8946fn cmd_doctor(as_json: bool, recent_rejections: usize) -> Result<()> {
8951 let checks: Vec<DoctorCheck> = vec![
8952 check_daemon_health(),
8953 check_daemon_pid_consistency(),
8954 check_relay_reachable(),
8955 check_pair_rejections(recent_rejections),
8956 check_cursor_progress(),
8957 ];
8958
8959 let fails = checks.iter().filter(|c| c.status == "FAIL").count();
8960 let warns = checks.iter().filter(|c| c.status == "WARN").count();
8961
8962 if as_json {
8963 println!(
8964 "{}",
8965 serde_json::to_string(&json!({
8966 "checks": checks,
8967 "fail_count": fails,
8968 "warn_count": warns,
8969 "ok": fails == 0,
8970 }))?
8971 );
8972 } else {
8973 println!("wire doctor — {} checks", checks.len());
8974 for c in &checks {
8975 let bullet = match c.status.as_str() {
8976 "PASS" => "✓",
8977 "WARN" => "!",
8978 "FAIL" => "✗",
8979 _ => "?",
8980 };
8981 println!(" {bullet} [{}] {}: {}", c.status, c.id, c.detail);
8982 if let Some(fix) = &c.fix {
8983 println!(" fix: {fix}");
8984 }
8985 }
8986 println!();
8987 if fails == 0 && warns == 0 {
8988 println!("ALL GREEN");
8989 } else {
8990 println!("{fails} FAIL, {warns} WARN");
8991 }
8992 }
8993
8994 if fails > 0 {
8995 std::process::exit(1);
8996 }
8997 Ok(())
8998}
8999
9000fn check_daemon_health() -> DoctorCheck {
9007 let snap = crate::ensure_up::daemon_liveness();
9013 let pgrep_pids = &snap.pgrep_pids;
9014 let pidfile_pid = snap.pidfile_pid;
9015 let pidfile_alive = snap.pidfile_alive;
9016 let orphan_pids = &snap.orphan_pids;
9017
9018 let fmt_pids = |xs: &[u32]| -> String {
9019 xs.iter()
9020 .map(|p| p.to_string())
9021 .collect::<Vec<_>>()
9022 .join(", ")
9023 };
9024
9025 match (pgrep_pids.len(), pidfile_alive, orphan_pids.is_empty()) {
9026 (0, _, _) => DoctorCheck::fail(
9027 "daemon",
9028 "no `wire daemon` process running — nothing pulling inbox or pushing outbox",
9029 "`wire daemon &` to start, or re-run `wire up <handle>@<relay>` to bootstrap",
9030 ),
9031 (1, true, true) => DoctorCheck::pass(
9033 "daemon",
9034 format!(
9035 "one daemon running (pid {}, matches pidfile)",
9036 pgrep_pids[0]
9037 ),
9038 ),
9039 (n, true, false) => DoctorCheck::fail(
9041 "daemon",
9042 format!(
9043 "{n} `wire daemon` processes running (pids: {}); pidfile claims pid {} but pgrep also sees orphan(s): {}. \
9044 The orphans race the relay cursor — they advance past events your current binary can't process. \
9045 (Issue #2 exact class.)",
9046 fmt_pids(pgrep_pids),
9047 pidfile_pid.unwrap(),
9048 fmt_pids(orphan_pids),
9049 ),
9050 "`wire upgrade` kills all orphans and spawns a fresh daemon with a clean pidfile",
9051 ),
9052 (n, false, _) => DoctorCheck::fail(
9054 "daemon",
9055 format!(
9056 "{n} `wire daemon` process(es) running (pids: {}) but pidfile {} — \
9057 every running daemon is an orphan, advancing the cursor without coordinating with the current CLI. \
9058 (Issue #2 exact class: doctor previously PASSed this state while `wire status` said DOWN.)",
9059 fmt_pids(pgrep_pids),
9060 match pidfile_pid {
9061 Some(p) => format!("claims pid {p} which is dead"),
9062 None => "is missing".to_string(),
9063 },
9064 ),
9065 "`wire upgrade` to kill the orphan(s) and spawn a fresh daemon",
9066 ),
9067 (n, true, true) => DoctorCheck::warn(
9069 "daemon",
9070 format!(
9071 "{n} `wire daemon` processes running (pids: {}). Multiple daemons race the relay cursor.",
9072 fmt_pids(pgrep_pids)
9073 ),
9074 "kill all-but-one: `pkill -f \"wire daemon\"; wire daemon &`",
9075 ),
9076 }
9077}
9078
9079fn check_daemon_pid_consistency() -> DoctorCheck {
9091 let snap = crate::ensure_up::daemon_liveness();
9092 match &snap.record {
9093 crate::ensure_up::PidRecord::Missing => DoctorCheck::pass(
9094 "daemon_pid_consistency",
9095 "no daemon.pid yet — fresh box or daemon never started",
9096 ),
9097 crate::ensure_up::PidRecord::Corrupt(reason) => DoctorCheck::warn(
9098 "daemon_pid_consistency",
9099 format!("daemon.pid is corrupt: {reason}"),
9100 "delete state/wire/daemon.pid; next `wire daemon &` will rewrite",
9101 ),
9102 crate::ensure_up::PidRecord::LegacyInt(pid) => {
9103 let pid = *pid;
9106 if !crate::ensure_up::pid_is_alive(pid) {
9107 return DoctorCheck::warn(
9108 "daemon_pid_consistency",
9109 format!(
9110 "daemon.pid (legacy-int) points at pid {pid} which is not running. \
9111 Stale pidfile from a crashed pre-0.5.11 daemon. \
9112 (Issue #2: this surface used to PASS while `wire status` said DOWN.)"
9113 ),
9114 "`wire upgrade` (kills any orphan + spawns a fresh daemon with JSON pidfile)",
9115 );
9116 }
9117 DoctorCheck::warn(
9118 "daemon_pid_consistency",
9119 format!(
9120 "daemon.pid is legacy-int form (pid={pid}, no version/bin_path metadata). \
9121 Daemon was started by a pre-0.5.11 binary."
9122 ),
9123 "run `wire upgrade` to kill the old daemon and start a fresh one with the JSON pidfile",
9124 )
9125 }
9126 crate::ensure_up::PidRecord::Json(d) => {
9127 if !snap.pidfile_alive {
9131 return DoctorCheck::warn(
9132 "daemon_pid_consistency",
9133 format!(
9134 "daemon.pid records pid {pid} (v{version}) but that process is not running — \
9135 pidfile is stale. `wire status` will report DOWN, but pre-v0.5.19 doctor \
9136 silently PASSed this state and ignored any live orphan daemons (#2 root cause).",
9137 pid = d.pid,
9138 version = d.version,
9139 ),
9140 "`wire upgrade` to clean up the stale pidfile + spawn a fresh daemon \
9141 (kills any orphan daemon advancing the cursor without coordination)",
9142 );
9143 }
9144 let mut issues: Vec<String> = Vec::new();
9145 if d.schema != crate::ensure_up::DAEMON_PID_SCHEMA {
9146 issues.push(format!(
9147 "schema={} (expected {})",
9148 d.schema,
9149 crate::ensure_up::DAEMON_PID_SCHEMA
9150 ));
9151 }
9152 let cli_version = env!("CARGO_PKG_VERSION");
9153 if d.version != cli_version {
9154 issues.push(format!("version daemon={} cli={cli_version}", d.version));
9155 }
9156 if !std::path::Path::new(&d.bin_path).exists() {
9157 issues.push(format!("bin_path {} missing on disk", d.bin_path));
9158 }
9159 if let Ok(card) = config::read_agent_card()
9161 && let Some(current_did) = card.get("did").and_then(Value::as_str)
9162 && let Some(recorded_did) = &d.did
9163 && recorded_did != current_did
9164 {
9165 issues.push(format!(
9166 "did daemon={recorded_did} config={current_did} — identity drift"
9167 ));
9168 }
9169 if let Ok(state) = config::read_relay_state()
9170 && let Some(current_relay) = state
9171 .get("self")
9172 .and_then(|s| s.get("relay_url"))
9173 .and_then(Value::as_str)
9174 && let Some(recorded_relay) = &d.relay_url
9175 && recorded_relay != current_relay
9176 {
9177 issues.push(format!(
9178 "relay_url daemon={recorded_relay} config={current_relay} — relay-migration drift"
9179 ));
9180 }
9181 if issues.is_empty() {
9182 DoctorCheck::pass(
9183 "daemon_pid_consistency",
9184 format!(
9185 "daemon v{} bound to {} as {}",
9186 d.version,
9187 d.relay_url.as_deref().unwrap_or("?"),
9188 d.did.as_deref().unwrap_or("?")
9189 ),
9190 )
9191 } else {
9192 DoctorCheck::warn(
9193 "daemon_pid_consistency",
9194 format!("daemon pidfile drift: {}", issues.join("; ")),
9195 "`wire upgrade` to atomically restart daemon with current config".to_string(),
9196 )
9197 }
9198 }
9199 }
9200}
9201
9202fn check_relay_reachable() -> DoctorCheck {
9204 let state = match config::read_relay_state() {
9205 Ok(s) => s,
9206 Err(e) => {
9207 return DoctorCheck::fail(
9208 "relay",
9209 format!("could not read relay state: {e}"),
9210 "run `wire up <handle>@<relay>` to bootstrap",
9211 );
9212 }
9213 };
9214 let url = state
9215 .get("self")
9216 .and_then(|s| s.get("relay_url"))
9217 .and_then(Value::as_str)
9218 .unwrap_or("");
9219 if url.is_empty() {
9220 return DoctorCheck::warn(
9221 "relay",
9222 "no relay bound — wire send/pull will not work",
9223 "run `wire bind-relay <url>` or `wire up <handle>@<relay>`",
9224 );
9225 }
9226 let client = crate::relay_client::RelayClient::new(url);
9227 match client.check_healthz() {
9228 Ok(()) => DoctorCheck::pass("relay", format!("{url} healthz=200")),
9229 Err(e) => DoctorCheck::fail(
9230 "relay",
9231 format!("{url} unreachable: {e}"),
9232 format!("network reachable to {url}? relay running? check `curl {url}/healthz`"),
9233 ),
9234 }
9235}
9236
9237fn check_pair_rejections(recent_n: usize) -> DoctorCheck {
9241 let path = match config::state_dir() {
9242 Ok(d) => d.join("pair-rejected.jsonl"),
9243 Err(e) => {
9244 return DoctorCheck::warn(
9245 "pair_rejections",
9246 format!("could not resolve state dir: {e}"),
9247 "set WIRE_HOME or fix XDG_STATE_HOME",
9248 );
9249 }
9250 };
9251 if !path.exists() {
9252 return DoctorCheck::pass(
9253 "pair_rejections",
9254 "no pair-rejected.jsonl — no recorded pair failures",
9255 );
9256 }
9257 let body = match std::fs::read_to_string(&path) {
9258 Ok(b) => b,
9259 Err(e) => {
9260 return DoctorCheck::warn(
9261 "pair_rejections",
9262 format!("could not read {path:?}: {e}"),
9263 "check file permissions",
9264 );
9265 }
9266 };
9267 let lines: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
9268 if lines.is_empty() {
9269 return DoctorCheck::pass("pair_rejections", "pair-rejected.jsonl present but empty");
9270 }
9271 let total = lines.len();
9272 let recent: Vec<&str> = lines.iter().rev().take(recent_n).rev().copied().collect();
9273 let mut summary: Vec<String> = Vec::new();
9274 for line in &recent {
9275 if let Ok(rec) = serde_json::from_str::<Value>(line) {
9276 let peer = rec.get("peer").and_then(Value::as_str).unwrap_or("?");
9277 let code = rec.get("code").and_then(Value::as_str).unwrap_or("?");
9278 summary.push(format!("{peer}/{code}"));
9279 }
9280 }
9281 DoctorCheck::warn(
9282 "pair_rejections",
9283 format!(
9284 "{total} pair failures recorded. recent: [{}]",
9285 summary.join(", ")
9286 ),
9287 format!(
9288 "inspect {path:?} for full details. Each entry is a pair-flow error that previously silently dropped — re-run `wire pair <handle>@<relay>` to retry."
9289 ),
9290 )
9291}
9292
9293fn check_cursor_progress() -> DoctorCheck {
9298 let state = match config::read_relay_state() {
9299 Ok(s) => s,
9300 Err(e) => {
9301 return DoctorCheck::warn(
9302 "cursor",
9303 format!("could not read relay state: {e}"),
9304 "check ~/Library/Application Support/wire/relay.json",
9305 );
9306 }
9307 };
9308 let cursor = state
9309 .get("self")
9310 .and_then(|s| s.get("last_pulled_event_id"))
9311 .and_then(Value::as_str)
9312 .map(|s| s.chars().take(16).collect::<String>())
9313 .unwrap_or_else(|| "<none>".to_string());
9314 DoctorCheck::pass(
9315 "cursor",
9316 format!(
9317 "current cursor: {cursor}. P0.1 cursor blocking is active — see `wire pull --json` for cursor_blocked / rejected[].blocks_cursor entries."
9318 ),
9319 )
9320}
9321
9322#[cfg(test)]
9323mod doctor_tests {
9324 use super::*;
9325
9326 #[test]
9327 fn doctor_check_constructors_set_status_correctly() {
9328 let p = DoctorCheck::pass("x", "ok");
9333 assert_eq!(p.status, "PASS");
9334 assert_eq!(p.fix, None);
9335
9336 let w = DoctorCheck::warn("x", "watch out", "do this");
9337 assert_eq!(w.status, "WARN");
9338 assert_eq!(w.fix, Some("do this".to_string()));
9339
9340 let f = DoctorCheck::fail("x", "broken", "fix it");
9341 assert_eq!(f.status, "FAIL");
9342 assert_eq!(f.fix, Some("fix it".to_string()));
9343 }
9344
9345 #[test]
9346 fn check_pair_rejections_no_file_is_pass() {
9347 config::test_support::with_temp_home(|| {
9350 config::ensure_dirs().unwrap();
9351 let c = check_pair_rejections(5);
9352 assert_eq!(c.status, "PASS", "no file should be PASS, got {c:?}");
9353 });
9354 }
9355
9356 #[test]
9357 fn check_pair_rejections_with_entries_warns() {
9358 config::test_support::with_temp_home(|| {
9362 config::ensure_dirs().unwrap();
9363 crate::pair_invite::record_pair_rejection(
9364 "willard",
9365 "pair_drop_ack_send_failed",
9366 "POST 502",
9367 );
9368 let c = check_pair_rejections(5);
9369 assert_eq!(c.status, "WARN");
9370 assert!(c.detail.contains("1 pair failures"));
9371 assert!(c.detail.contains("willard/pair_drop_ack_send_failed"));
9372 });
9373 }
9374}
9375
9376fn cmd_up(handle_arg: &str, name: Option<&str>, as_json: bool) -> Result<()> {
9388 let (nick, relay_url) = match handle_arg.split_once('@') {
9389 Some((n, host)) => {
9390 let url = if host.starts_with("http://") || host.starts_with("https://") {
9391 host.to_string()
9392 } else {
9393 format!("https://{host}")
9394 };
9395 (n.to_string(), url)
9396 }
9397 None => (
9398 handle_arg.to_string(),
9399 crate::pair_invite::DEFAULT_RELAY.to_string(),
9400 ),
9401 };
9402
9403 let mut report: Vec<(String, String)> = Vec::new();
9404 let mut step = |stage: &str, detail: String| {
9405 report.push((stage.to_string(), detail.clone()));
9406 if !as_json {
9407 eprintln!("wire up: {stage} — {detail}");
9408 }
9409 };
9410
9411 if config::is_initialized()? {
9413 let card = config::read_agent_card()?;
9414 let existing_did = card.get("did").and_then(Value::as_str).unwrap_or("");
9415 let existing_handle = crate::agent_card::display_handle_from_did(existing_did).to_string();
9416 if existing_handle != nick {
9417 bail!(
9418 "wire up: already initialized as {existing_handle:?} but you asked for {nick:?}. \
9419 Either run with the existing handle (`wire up {existing_handle}@<relay>`) or \
9420 delete `{:?}` to start fresh.",
9421 config::config_dir()?
9422 );
9423 }
9424 step("init", format!("already initialized as {existing_handle}"));
9425 } else {
9426 cmd_init(&nick, name, Some(&relay_url), false)?;
9427 step(
9428 "init",
9429 format!("created identity {nick} bound to {relay_url}"),
9430 );
9431 }
9432
9433 let relay_state = config::read_relay_state()?;
9437 let bound_relay = relay_state
9438 .get("self")
9439 .and_then(|s| s.get("relay_url"))
9440 .and_then(Value::as_str)
9441 .unwrap_or("")
9442 .to_string();
9443 if bound_relay.is_empty() {
9444 cmd_bind_relay(
9448 &relay_url, false, false,
9449 )?;
9450 step("bind-relay", format!("bound to {relay_url}"));
9451 } else if bound_relay != relay_url {
9452 step(
9453 "bind-relay",
9454 format!(
9455 "WARNING: identity bound to {bound_relay} but you specified {relay_url}. \
9456 Keeping existing binding. Run `wire bind-relay {relay_url}` to switch."
9457 ),
9458 );
9459 } else {
9460 step("bind-relay", format!("already bound to {bound_relay}"));
9461 }
9462
9463 match cmd_claim(
9466 &nick,
9467 Some(&relay_url),
9468 None,
9469 false,
9470 false,
9471 ) {
9472 Ok(()) => step(
9473 "claim",
9474 format!("{nick}@{} claimed", strip_proto(&relay_url)),
9475 ),
9476 Err(e) => step(
9477 "claim",
9478 format!("WARNING: claim failed: {e}. You can retry `wire claim {nick}`."),
9479 ),
9480 }
9481
9482 match crate::ensure_up::ensure_daemon_running() {
9484 Ok(true) => step("daemon", "started fresh background daemon".to_string()),
9485 Ok(false) => step("daemon", "already running".to_string()),
9486 Err(e) => step(
9487 "daemon",
9488 format!("WARNING: could not start daemon: {e}. Run `wire daemon &` manually."),
9489 ),
9490 }
9491
9492 let summary =
9494 "ready. `wire pair <peer>@<relay>` to pair, `wire send <peer> \"<msg>\"` to send, \
9495 `wire monitor` to watch incoming events."
9496 .to_string();
9497 step("ready", summary.clone());
9498
9499 if as_json {
9500 let steps_json: Vec<_> = report
9501 .iter()
9502 .map(|(k, v)| json!({"stage": k, "detail": v}))
9503 .collect();
9504 println!(
9505 "{}",
9506 serde_json::to_string(&json!({
9507 "nick": nick,
9508 "relay": relay_url,
9509 "steps": steps_json,
9510 }))?
9511 );
9512 }
9513 Ok(())
9514}
9515
9516fn strip_proto(url: &str) -> String {
9518 url.trim_start_matches("https://")
9519 .trim_start_matches("http://")
9520 .to_string()
9521}
9522
9523fn cmd_pair_megacommand(
9537 handle_arg: &str,
9538 relay_override: Option<&str>,
9539 timeout_secs: u64,
9540 _as_json: bool,
9541) -> Result<()> {
9542 let parsed = crate::pair_profile::parse_handle(handle_arg)?;
9543 let peer_handle = parsed.nick.clone();
9544
9545 eprintln!("wire pair: resolving {handle_arg}...");
9546 cmd_add(
9547 handle_arg,
9548 relay_override,
9549 false,
9550 false,
9551 )?;
9552
9553 eprintln!(
9554 "wire pair: intro delivered. waiting up to {timeout_secs}s for {peer_handle} \
9555 to ack (their daemon must be running + pulling)..."
9556 );
9557
9558 let _ = run_sync_pull();
9562
9563 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
9564 let poll_interval = std::time::Duration::from_millis(500);
9565
9566 loop {
9567 let _ = run_sync_pull();
9569 let relay_state = config::read_relay_state()?;
9570 let peer_entry = relay_state
9571 .get("peers")
9572 .and_then(|p| p.get(&peer_handle))
9573 .cloned();
9574 let token = peer_entry
9575 .as_ref()
9576 .and_then(|e| e.get("slot_token"))
9577 .and_then(Value::as_str)
9578 .unwrap_or("");
9579
9580 if !token.is_empty() {
9581 let trust = config::read_trust()?;
9583 let pinned_in_trust = trust
9584 .get("agents")
9585 .and_then(|a| a.get(&peer_handle))
9586 .is_some();
9587 println!(
9588 "wire pair: paired with {peer_handle}.\n trust: {} bilateral: yes (slot_token recorded)\n next: `wire send {peer_handle} \"<msg>\"`",
9589 if pinned_in_trust {
9590 "VERIFIED"
9591 } else {
9592 "MISSING (bug)"
9593 }
9594 );
9595 return Ok(());
9596 }
9597
9598 if std::time::Instant::now() >= deadline {
9599 bail!(
9606 "wire pair: timed out after {timeout_secs}s. \
9607 peer {peer_handle} never sent pair_drop_ack. \
9608 likely causes: (a) their daemon is down — ask them to run \
9609 `wire status` and `wire daemon &`; (b) their binary is older \
9610 than 0.5.x and doesn't understand pair_drop events — ask \
9611 them to `wire upgrade`; (c) network / relay blip — re-run \
9612 `wire pair {handle_arg}` to retry."
9613 );
9614 }
9615
9616 std::thread::sleep(poll_interval);
9617 }
9618}
9619
9620fn cmd_claim(
9621 nick: &str,
9622 relay_override: Option<&str>,
9623 public_url: Option<&str>,
9624 hidden: bool,
9625 as_json: bool,
9626) -> Result<()> {
9627 if !crate::pair_profile::is_valid_nick(nick) {
9628 bail!(
9629 "phyllis: {nick:?} won't fit in the books — handles need 2-32 chars, lowercase [a-z0-9_-], not on the reserved list"
9630 );
9631 }
9632 let (_did, relay_url, slot_id, slot_token) =
9635 crate::pair_invite::ensure_self_with_relay(relay_override)?;
9636 let card = config::read_agent_card()?;
9637
9638 let client = crate::relay_client::RelayClient::new(&relay_url);
9639 let discoverable = if hidden { Some(false) } else { None };
9643 let resp =
9644 client.handle_claim_v2(nick, &slot_id, &slot_token, public_url, &card, discoverable)?;
9645
9646 if as_json {
9647 println!(
9648 "{}",
9649 serde_json::to_string(&json!({
9650 "nick": nick,
9651 "relay": relay_url,
9652 "response": resp,
9653 }))?
9654 );
9655 } else {
9656 let domain = public_url
9660 .unwrap_or(&relay_url)
9661 .trim_start_matches("https://")
9662 .trim_start_matches("http://")
9663 .trim_end_matches('/')
9664 .split('/')
9665 .next()
9666 .unwrap_or("<this-relay-domain>")
9667 .to_string();
9668 println!("claimed {nick} on {relay_url} — others can reach you at: {nick}@{domain}");
9669 println!("verify with: wire whois {nick}@{domain}");
9670 }
9671 Ok(())
9672}
9673
9674fn cmd_profile(action: ProfileAction) -> Result<()> {
9675 match action {
9676 ProfileAction::Set { field, value, json } => {
9677 let parsed: Value =
9681 serde_json::from_str(&value).unwrap_or(Value::String(value.clone()));
9682 let new_profile = crate::pair_profile::write_profile_field(&field, parsed)?;
9683 if json {
9684 println!(
9685 "{}",
9686 serde_json::to_string(&json!({
9687 "field": field,
9688 "profile": new_profile,
9689 }))?
9690 );
9691 } else {
9692 println!("profile.{field} set");
9693 }
9694 }
9695 ProfileAction::Get { json } => return cmd_whois(None, json, None),
9696 ProfileAction::Clear { field, json } => {
9697 let new_profile = crate::pair_profile::write_profile_field(&field, Value::Null)?;
9698 if json {
9699 println!(
9700 "{}",
9701 serde_json::to_string(&json!({
9702 "field": field,
9703 "cleared": true,
9704 "profile": new_profile,
9705 }))?
9706 );
9707 } else {
9708 println!("profile.{field} cleared");
9709 }
9710 }
9711 }
9712 Ok(())
9713}
9714
9715fn cmd_setup(apply: bool) -> Result<()> {
9718 use std::path::PathBuf;
9719
9720 let entry = json!({"command": "wire", "args": ["mcp"]});
9721 let entry_pretty = serde_json::to_string_pretty(&json!({"wire": &entry}))?;
9722
9723 let mut targets: Vec<(&str, PathBuf)> = Vec::new();
9726 if let Some(home) = dirs::home_dir() {
9727 targets.push(("Claude Code", home.join(".claude.json")));
9730 targets.push(("Claude Code (alt)", home.join(".config/claude/mcp.json")));
9732 #[cfg(target_os = "macos")]
9734 targets.push((
9735 "Claude Desktop (macOS)",
9736 home.join("Library/Application Support/Claude/claude_desktop_config.json"),
9737 ));
9738 #[cfg(target_os = "windows")]
9740 if let Ok(appdata) = std::env::var("APPDATA") {
9741 targets.push((
9742 "Claude Desktop (Windows)",
9743 PathBuf::from(appdata).join("Claude/claude_desktop_config.json"),
9744 ));
9745 }
9746 targets.push(("Cursor", home.join(".cursor/mcp.json")));
9748 }
9749 targets.push(("project-local (.mcp.json)", PathBuf::from(".mcp.json")));
9751
9752 println!("wire setup\n");
9753 println!("MCP server snippet (add this to your client's mcpServers):");
9754 println!();
9755 println!("{entry_pretty}");
9756 println!();
9757
9758 if !apply {
9759 println!("Probable MCP host config locations on this machine:");
9760 for (name, path) in &targets {
9761 let marker = if path.exists() {
9762 "✓ found"
9763 } else {
9764 " (would create)"
9765 };
9766 println!(" {marker:14} {name}: {}", path.display());
9767 }
9768 println!();
9769 println!("Run `wire setup --apply` to merge wire into each config above.");
9770 println!(
9771 "Existing entries with a different command keep yours unchanged unless wire's exact entry is missing."
9772 );
9773 return Ok(());
9774 }
9775
9776 let mut modified: Vec<String> = Vec::new();
9777 let mut skipped: Vec<String> = Vec::new();
9778 for (name, path) in &targets {
9779 match upsert_mcp_entry(path, "wire", &entry) {
9780 Ok(true) => modified.push(format!("✓ {name} ({})", path.display())),
9781 Ok(false) => skipped.push(format!(" {name} ({}): already configured", path.display())),
9782 Err(e) => skipped.push(format!("✗ {name} ({}): {e}", path.display())),
9783 }
9784 }
9785 if !modified.is_empty() {
9786 println!("Modified:");
9787 for line in &modified {
9788 println!(" {line}");
9789 }
9790 println!();
9791 println!("Restart the app(s) above to load wire MCP.");
9792 }
9793 if !skipped.is_empty() {
9794 println!();
9795 println!("Skipped:");
9796 for line in &skipped {
9797 println!(" {line}");
9798 }
9799 }
9800 Ok(())
9801}
9802
9803fn upsert_mcp_entry(path: &std::path::Path, server_name: &str, entry: &Value) -> Result<bool> {
9806 let mut cfg: Value = if path.exists() {
9807 let body = std::fs::read_to_string(path).context("reading config")?;
9808 serde_json::from_str(&body).unwrap_or_else(|_| json!({}))
9809 } else {
9810 json!({})
9811 };
9812 if !cfg.is_object() {
9813 cfg = json!({});
9814 }
9815 let root = cfg.as_object_mut().unwrap();
9816 let servers = root
9817 .entry("mcpServers".to_string())
9818 .or_insert_with(|| json!({}));
9819 if !servers.is_object() {
9820 *servers = json!({});
9821 }
9822 let map = servers.as_object_mut().unwrap();
9823 if map.get(server_name) == Some(entry) {
9824 return Ok(false);
9825 }
9826 map.insert(server_name.to_string(), entry.clone());
9827 if let Some(parent) = path.parent()
9828 && !parent.as_os_str().is_empty()
9829 {
9830 std::fs::create_dir_all(parent).context("creating parent dir")?;
9831 }
9832 let out = serde_json::to_string_pretty(&cfg)? + "\n";
9833 std::fs::write(path, out).context("writing config")?;
9834 Ok(true)
9835}
9836
9837#[allow(clippy::too_many_arguments)]
9840fn cmd_reactor(
9841 on_event: &str,
9842 peer_filter: Option<&str>,
9843 kind_filter: Option<&str>,
9844 verified_only: bool,
9845 interval_secs: u64,
9846 once: bool,
9847 dry_run: bool,
9848 max_per_minute: u32,
9849 max_chain_depth: u32,
9850) -> Result<()> {
9851 use crate::inbox_watch::{InboxEvent, InboxWatcher};
9852 use std::collections::{HashMap, HashSet, VecDeque};
9853 use std::io::Write;
9854 use std::process::{Command, Stdio};
9855 use std::time::{Duration, Instant};
9856
9857 let cursor_path = config::state_dir()?.join("reactor.cursor");
9858 let emitted_path = config::state_dir()?.join("reactor-emitted.log");
9867 let mut emitted_ids: HashSet<String> = HashSet::new();
9868 if emitted_path.exists()
9869 && let Ok(body) = std::fs::read_to_string(&emitted_path)
9870 {
9871 for line in body.lines() {
9872 let t = line.trim();
9873 if !t.is_empty() {
9874 emitted_ids.insert(t.to_string());
9875 }
9876 }
9877 }
9878 let outbox_dir = config::outbox_dir()?;
9880 let mut outbox_cursors: HashMap<String, u64> = HashMap::new();
9883
9884 let mut watcher = InboxWatcher::from_cursor_file(&cursor_path)?;
9885
9886 let kind_num: Option<u32> = match kind_filter {
9887 Some(k) => Some(parse_kind(k)?),
9888 None => None,
9889 };
9890
9891 let mut peer_dispatch_log: HashMap<String, VecDeque<Instant>> = HashMap::new();
9893
9894 let dispatch = |ev: &InboxEvent,
9895 peer_dispatch_log: &mut HashMap<String, VecDeque<Instant>>,
9896 emitted_ids: &HashSet<String>|
9897 -> Result<bool> {
9898 if let Some(p) = peer_filter
9899 && ev.peer != p
9900 {
9901 return Ok(false);
9902 }
9903 if verified_only && !ev.verified {
9904 return Ok(false);
9905 }
9906 if let Some(want) = kind_num {
9907 let ev_kind = ev.raw.get("kind").and_then(Value::as_u64).map(|n| n as u32);
9908 if ev_kind != Some(want) {
9909 return Ok(false);
9910 }
9911 }
9912
9913 if max_chain_depth > 0 {
9917 let body_str = match &ev.raw["body"] {
9918 Value::String(s) => s.clone(),
9919 other => serde_json::to_string(other).unwrap_or_default(),
9920 };
9921 if let Some(referenced) = parse_re_marker(&body_str) {
9922 let matched = emitted_ids.contains(&referenced)
9925 || emitted_ids.iter().any(|full| full.starts_with(&referenced));
9926 if matched {
9927 eprintln!(
9928 "wire reactor: skip {} from {} — chain-depth (reply to our re:{})",
9929 ev.event_id, ev.peer, referenced
9930 );
9931 return Ok(false);
9932 }
9933 }
9934 }
9935
9936 if max_per_minute > 0 {
9938 let now = Instant::now();
9939 let win = peer_dispatch_log.entry(ev.peer.clone()).or_default();
9940 while let Some(&front) = win.front() {
9941 if now.duration_since(front) > Duration::from_secs(60) {
9942 win.pop_front();
9943 } else {
9944 break;
9945 }
9946 }
9947 if win.len() as u32 >= max_per_minute {
9948 eprintln!(
9949 "wire reactor: skip {} from {} — rate-limit ({}/min reached)",
9950 ev.event_id, ev.peer, max_per_minute
9951 );
9952 return Ok(false);
9953 }
9954 win.push_back(now);
9955 }
9956
9957 if dry_run {
9958 println!("{}", serde_json::to_string(&ev.raw)?);
9959 return Ok(true);
9960 }
9961
9962 let mut child = Command::new("sh")
9963 .arg("-c")
9964 .arg(on_event)
9965 .stdin(Stdio::piped())
9966 .stdout(Stdio::inherit())
9967 .stderr(Stdio::inherit())
9968 .env("WIRE_EVENT_PEER", &ev.peer)
9969 .env("WIRE_EVENT_ID", &ev.event_id)
9970 .env("WIRE_EVENT_KIND", &ev.kind)
9971 .spawn()
9972 .with_context(|| format!("spawning reactor handler: {on_event}"))?;
9973 if let Some(mut stdin) = child.stdin.take() {
9974 let body = serde_json::to_vec(&ev.raw)?;
9975 let _ = stdin.write_all(&body);
9976 let _ = stdin.write_all(b"\n");
9977 }
9978 std::mem::drop(child);
9979 Ok(true)
9980 };
9981
9982 let scan_outbox = |emitted_ids: &mut HashSet<String>,
9984 outbox_cursors: &mut HashMap<String, u64>|
9985 -> Result<usize> {
9986 if !outbox_dir.exists() {
9987 return Ok(0);
9988 }
9989 let mut added = 0;
9990 let mut new_ids: Vec<String> = Vec::new();
9991 for entry in std::fs::read_dir(&outbox_dir)?.flatten() {
9992 let path = entry.path();
9993 if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
9994 continue;
9995 }
9996 let peer = match path.file_stem().and_then(|s| s.to_str()) {
9997 Some(s) => s.to_string(),
9998 None => continue,
9999 };
10000 let cur_len = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
10001 let start = *outbox_cursors.get(&peer).unwrap_or(&0);
10002 if cur_len <= start {
10003 outbox_cursors.insert(peer, start);
10004 continue;
10005 }
10006 let body = std::fs::read_to_string(&path).unwrap_or_default();
10007 let tail = &body[start as usize..];
10008 for line in tail.lines() {
10009 if let Ok(v) = serde_json::from_str::<Value>(line)
10010 && let Some(eid) = v.get("event_id").and_then(Value::as_str)
10011 && emitted_ids.insert(eid.to_string())
10012 {
10013 new_ids.push(eid.to_string());
10014 added += 1;
10015 }
10016 }
10017 outbox_cursors.insert(peer, cur_len);
10018 }
10019 if !new_ids.is_empty() {
10020 let mut all: Vec<String> = emitted_ids.iter().cloned().collect();
10022 if all.len() > 500 {
10023 all.sort();
10024 let drop_n = all.len() - 500;
10025 let dropped: HashSet<String> = all.iter().take(drop_n).cloned().collect();
10026 emitted_ids.retain(|x| !dropped.contains(x));
10027 all = emitted_ids.iter().cloned().collect();
10028 }
10029 let _ = std::fs::write(&emitted_path, all.join("\n") + "\n");
10030 }
10031 Ok(added)
10032 };
10033
10034 let sweep = |watcher: &mut InboxWatcher,
10035 emitted_ids: &mut HashSet<String>,
10036 outbox_cursors: &mut HashMap<String, u64>,
10037 peer_dispatch_log: &mut HashMap<String, VecDeque<Instant>>|
10038 -> Result<usize> {
10039 let _ = scan_outbox(emitted_ids, outbox_cursors);
10041
10042 let events = watcher.poll()?;
10043 let mut fired = 0usize;
10044 for ev in &events {
10045 match dispatch(ev, peer_dispatch_log, emitted_ids) {
10046 Ok(true) => fired += 1,
10047 Ok(false) => {}
10048 Err(e) => eprintln!("wire reactor: handler error for {}: {e}", ev.event_id),
10049 }
10050 }
10051 watcher.save_cursors(&cursor_path)?;
10052 Ok(fired)
10053 };
10054
10055 if once {
10056 sweep(
10057 &mut watcher,
10058 &mut emitted_ids,
10059 &mut outbox_cursors,
10060 &mut peer_dispatch_log,
10061 )?;
10062 return Ok(());
10063 }
10064 let interval = std::time::Duration::from_secs(interval_secs.max(1));
10065 loop {
10066 if let Err(e) = sweep(
10067 &mut watcher,
10068 &mut emitted_ids,
10069 &mut outbox_cursors,
10070 &mut peer_dispatch_log,
10071 ) {
10072 eprintln!("wire reactor: sweep error: {e}");
10073 }
10074 std::thread::sleep(interval);
10075 }
10076}
10077
10078fn parse_re_marker(body: &str) -> Option<String> {
10081 let needle = "(re:";
10082 let i = body.find(needle)?;
10083 let rest = &body[i + needle.len()..];
10084 let end = rest.find(')')?;
10085 let id = rest[..end].trim().to_string();
10086 if id.is_empty() {
10087 return None;
10088 }
10089 Some(id)
10090}
10091
10092fn cmd_notify(
10095 interval_secs: u64,
10096 peer_filter: Option<&str>,
10097 once: bool,
10098 as_json: bool,
10099) -> Result<()> {
10100 use crate::inbox_watch::InboxWatcher;
10101 let cursor_path = config::state_dir()?.join("notify.cursor");
10102 let mut watcher = InboxWatcher::from_cursor_file(&cursor_path)?;
10103
10104 let sweep = |watcher: &mut InboxWatcher| -> Result<()> {
10105 let events = watcher.poll()?;
10106 for ev in events {
10107 if let Some(p) = peer_filter
10108 && ev.peer != p
10109 {
10110 continue;
10111 }
10112 if as_json {
10113 println!("{}", serde_json::to_string(&ev)?);
10114 } else {
10115 os_notify_inbox_event(&ev);
10116 }
10117 }
10118 watcher.save_cursors(&cursor_path)?;
10119 Ok(())
10120 };
10121
10122 if once {
10123 return sweep(&mut watcher);
10124 }
10125
10126 let interval = std::time::Duration::from_secs(interval_secs.max(1));
10127 loop {
10128 if let Err(e) = sweep(&mut watcher) {
10129 eprintln!("wire notify: sweep error: {e}");
10130 }
10131 std::thread::sleep(interval);
10132 }
10133}
10134
10135fn os_notify_inbox_event(ev: &crate::inbox_watch::InboxEvent) {
10136 let title = if ev.verified {
10137 format!("wire ← {}", ev.peer)
10138 } else {
10139 format!("wire ← {} (UNVERIFIED)", ev.peer)
10140 };
10141 let body = format!("{}: {}", ev.kind, ev.body_preview);
10142 crate::os_notify::toast(&title, &body);
10143}
10144
10145#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
10146fn os_toast(title: &str, body: &str) {
10147 eprintln!("[wire notify] {title}\n {body}");
10148}
10149
10150