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 #[command(hide = true)]
53 Init {
54 handle: String,
56 #[arg(long)]
58 name: Option<String>,
59 #[arg(long)]
64 relay: Option<String>,
65 #[arg(long, conflicts_with = "relay")]
70 offline: bool,
71 #[arg(long)]
73 json: bool,
74 },
75 Whoami {
79 #[arg(long)]
80 json: bool,
81 #[arg(long, conflicts_with = "json")]
84 short: bool,
85 #[arg(long, conflicts_with_all = ["json", "short"])]
88 colored: bool,
89 },
90 Peers {
92 #[arg(long)]
93 json: bool,
94 },
95 Completions {
106 #[arg(value_enum)]
108 shell: clap_complete::Shell,
109 },
110 Here {
118 #[arg(long)]
119 json: bool,
120 },
121 Pending {
126 #[arg(long)]
127 json: bool,
128 },
129 Send {
137 peer: String,
139 kind_or_body: String,
144 body: Option<String>,
148 #[arg(long)]
150 deadline: Option<String>,
151 #[arg(long)]
156 no_auto_pair: bool,
157 #[arg(long)]
159 json: bool,
160 },
161 Dial {
178 name: String,
182 message: Option<String>,
186 #[arg(long)]
188 json: bool,
189 },
190 Tail {
192 peer: Option<String>,
194 #[arg(long)]
196 json: bool,
197 #[arg(long, default_value_t = 0)]
199 limit: usize,
200 },
201 Monitor {
212 #[arg(long)]
214 peer: Option<String>,
215 #[arg(long)]
217 json: bool,
218 #[arg(long)]
221 include_handshake: bool,
222 #[arg(long, default_value_t = 500)]
224 interval_ms: u64,
225 #[arg(long, default_value_t = 0)]
227 replay: usize,
228 },
229 Verify {
231 path: String,
233 #[arg(long)]
235 json: bool,
236 },
237 Mcp,
241 RelayServer {
243 #[arg(long, default_value = "127.0.0.1:8770")]
245 bind: String,
246 #[arg(long)]
254 local_only: bool,
255 #[arg(long)]
261 uds: Option<std::path::PathBuf>,
262 },
263 BindRelay {
272 url: String,
274 #[arg(long)]
280 scope: Option<String>,
281 #[arg(long)]
287 replace: bool,
288 #[arg(long)]
294 migrate_pinned: bool,
295 #[arg(long)]
296 json: bool,
297 },
298 AddPeerSlot {
301 handle: String,
303 url: String,
305 slot_id: String,
307 slot_token: String,
309 #[arg(long)]
310 json: bool,
311 },
312 Push {
314 peer: Option<String>,
316 #[arg(long)]
317 json: bool,
318 },
319 Pull {
321 #[arg(long)]
322 json: bool,
323 },
324 Status {
327 #[arg(long)]
329 peer: Option<String>,
330 #[arg(long)]
331 json: bool,
332 },
333 Responder {
335 #[command(subcommand)]
336 command: ResponderCommand,
337 },
338 Pin {
341 card_file: String,
343 #[arg(long)]
344 json: bool,
345 },
346 RotateSlot {
357 #[arg(long)]
360 no_announce: bool,
361 #[arg(long)]
362 json: bool,
363 },
364 ForgetPeer {
368 handle: String,
370 #[arg(long)]
372 purge: bool,
373 #[arg(long)]
374 json: bool,
375 },
376 Daemon {
380 #[arg(long, default_value_t = 5)]
382 interval: u64,
383 #[arg(long)]
385 once: bool,
386 #[arg(long)]
387 json: bool,
388 },
389 #[command(hide = true)] PairHost {
395 #[arg(long)]
397 relay: String,
398 #[arg(long)]
402 yes: bool,
403 #[arg(long, default_value_t = 300)]
405 timeout: u64,
406 #[arg(long)]
412 detach: bool,
413 #[arg(long)]
415 json: bool,
416 },
417 #[command(alias = "join")]
421 #[command(hide = true)] PairJoin {
423 code_phrase: String,
425 #[arg(long)]
427 relay: String,
428 #[arg(long)]
429 yes: bool,
430 #[arg(long, default_value_t = 300)]
431 timeout: u64,
432 #[arg(long)]
434 detach: bool,
435 #[arg(long)]
437 json: bool,
438 },
439 #[command(hide = true)] PairConfirm {
444 code_phrase: String,
446 digits: String,
448 #[arg(long)]
450 json: bool,
451 },
452 #[command(hide = true)] PairList {
455 #[arg(long)]
457 json: bool,
458 #[arg(long)]
462 watch: bool,
463 #[arg(long, default_value_t = 1)]
465 watch_interval: u64,
466 },
467 #[command(hide = true)] PairCancel {
470 code_phrase: String,
471 #[arg(long)]
472 json: bool,
473 },
474 #[command(hide = true)] PairWatch {
485 code_phrase: String,
486 #[arg(long, default_value = "sas_ready")]
488 status: String,
489 #[arg(long, default_value_t = 300)]
491 timeout: u64,
492 #[arg(long)]
494 json: bool,
495 },
496 #[command(hide = true)] Pair {
510 handle: String,
513 #[arg(long)]
516 code: Option<String>,
517 #[arg(long, default_value = "https://wireup.net")]
519 relay: String,
520 #[arg(long)]
522 yes: bool,
523 #[arg(long, default_value_t = 300)]
525 timeout: u64,
526 #[arg(long)]
529 no_setup: bool,
530 #[arg(long)]
535 detach: bool,
536 },
537 #[command(hide = true)] PairAbandon {
544 code_phrase: String,
546 #[arg(long, default_value = "https://wireup.net")]
548 relay: String,
549 },
550 #[command(hide = true)] PairAccept {
557 peer: String,
559 #[arg(long)]
561 json: bool,
562 },
563 #[command(hide = true)] PairReject {
571 peer: String,
573 #[arg(long)]
575 json: bool,
576 },
577 #[command(hide = true)] PairListInbound {
584 #[arg(long)]
586 json: bool,
587 },
588 #[command(subcommand)]
598 Session(SessionCommand),
599 Identity {
604 #[command(subcommand)]
605 cmd: IdentityCommand,
606 },
607 #[command(subcommand)]
612 Mesh(MeshCommand),
613 Setup {
618 #[arg(long)]
620 apply: bool,
621 },
622 Whois {
626 handle: Option<String>,
628 #[arg(long)]
629 json: bool,
630 #[arg(long)]
633 relay: Option<String>,
634 },
635 Add {
641 handle: String,
644 #[arg(long)]
646 relay: Option<String>,
647 #[arg(long)]
655 local_sister: bool,
656 #[arg(long)]
657 json: bool,
658 },
659 Up {
672 relay: Option<String>,
676 #[arg(long)]
679 name: Option<String>,
680 #[arg(long)]
685 with_local: Option<String>,
686 #[arg(long)]
688 no_local: bool,
689 #[arg(long)]
690 json: bool,
691 },
692 Doctor {
699 #[arg(long)]
701 json: bool,
702 #[arg(long, default_value_t = 5)]
704 recent_rejections: usize,
705 },
706 Upgrade {
711 #[arg(long)]
714 check: bool,
715 #[arg(long)]
716 json: bool,
717 },
718 Service {
723 #[command(subcommand)]
724 action: ServiceAction,
725 },
726 Diag {
731 #[command(subcommand)]
732 action: DiagAction,
733 },
734 #[command(hide = true)]
746 Claim {
747 nick: String,
749 #[arg(long)]
751 relay: Option<String>,
752 #[arg(long)]
754 public_url: Option<String>,
755 #[arg(long)]
763 hidden: bool,
764 #[arg(long)]
765 json: bool,
766 },
767 Profile {
777 #[command(subcommand)]
778 action: ProfileAction,
779 },
780 #[command(hide = true)] Invite {
785 #[arg(long, default_value = "https://wireup.net")]
787 relay: String,
788 #[arg(long, default_value_t = 86_400)]
790 ttl: u64,
791 #[arg(long, default_value_t = 1)]
794 uses: u32,
795 #[arg(long)]
799 share: bool,
800 #[arg(long)]
802 json: bool,
803 },
804 Accept {
814 target: String,
816 #[arg(long)]
818 json: bool,
819 },
820 #[command(alias = "invite-accept")]
828 AcceptInvite {
829 url: String,
831 #[arg(long)]
833 json: bool,
834 },
835 Reject {
838 peer: String,
840 #[arg(long)]
842 json: bool,
843 },
844 Reactor {
850 #[arg(long)]
852 on_event: String,
853 #[arg(long)]
855 peer: Option<String>,
856 #[arg(long)]
858 kind: Option<String>,
859 #[arg(long, default_value_t = true)]
861 verified_only: bool,
862 #[arg(long, default_value_t = 2)]
864 interval: u64,
865 #[arg(long)]
867 once: bool,
868 #[arg(long)]
870 dry_run: bool,
871 #[arg(long, default_value_t = 6)]
875 max_per_minute: u32,
876 #[arg(long, default_value_t = 1)]
880 max_chain_depth: u32,
881 },
882 Notify {
887 #[arg(long, default_value_t = 2)]
889 interval: u64,
890 #[arg(long)]
892 peer: Option<String>,
893 #[arg(long)]
895 once: bool,
896 #[arg(long)]
900 json: bool,
901 },
902}
903
904#[derive(Subcommand, Debug)]
905pub enum DiagAction {
906 Tail {
908 #[arg(long, default_value_t = 20)]
909 limit: usize,
910 #[arg(long)]
911 json: bool,
912 },
913 Enable,
916 Disable,
918 Status {
920 #[arg(long)]
921 json: bool,
922 },
923}
924
925#[derive(Subcommand, Debug)]
926pub enum IdentityCommand {
927 Show {
930 #[arg(long)]
931 json: bool,
932 },
933 List {
938 #[arg(long)]
939 json: bool,
940 },
941 #[command(hide = true)]
949 Publish {
950 nick: String,
952 #[arg(long)]
955 relay: Option<String>,
956 #[arg(long, alias = "public")]
959 public_url: Option<String>,
960 #[arg(long)]
964 hidden: bool,
965 #[arg(long)]
966 json: bool,
967 },
968 Destroy {
972 name: String,
974 #[arg(long)]
976 force: bool,
977 #[arg(long)]
978 json: bool,
979 },
980 Create {
992 #[arg(long)]
995 name: Option<String>,
996 #[arg(long, conflicts_with = "local")]
999 anonymous: bool,
1000 #[arg(long)]
1003 local: bool,
1004 #[arg(long)]
1005 json: bool,
1006 },
1007 Persist {
1012 name: String,
1014 #[arg(long = "as", value_name = "NEW_NAME")]
1016 as_name: Option<String>,
1017 #[arg(long)]
1018 json: bool,
1019 },
1020 Demote {
1030 name: String,
1032 #[arg(long)]
1033 json: bool,
1034 },
1035}
1036
1037#[derive(Subcommand, Debug)]
1038pub enum SessionCommand {
1039 New {
1047 name: Option<String>,
1049 #[arg(long, default_value = "https://wireup.net")]
1051 relay: String,
1052 #[arg(long)]
1059 with_local: bool,
1060 #[arg(long, default_value = "http://127.0.0.1:8771")]
1064 local_relay: String,
1065 #[arg(long)]
1072 with_lan: bool,
1073 #[arg(long)]
1077 lan_relay: Option<String>,
1078 #[arg(long)]
1085 with_uds: bool,
1086 #[arg(long)]
1090 uds_socket: Option<std::path::PathBuf>,
1091 #[arg(long)]
1094 no_daemon: bool,
1095 #[arg(long)]
1103 local_only: bool,
1104 #[arg(long)]
1106 json: bool,
1107 },
1108 List {
1111 #[arg(long)]
1112 json: bool,
1113 },
1114 ListLocal {
1120 #[arg(long)]
1121 json: bool,
1122 },
1123 PairAllLocal {
1139 #[arg(long, default_value_t = 1)]
1144 settle_secs: u64,
1145 #[arg(long, default_value = "https://wireup.net")]
1150 federation_relay: String,
1151 #[arg(long)]
1152 json: bool,
1153 },
1154 MeshStatus {
1168 #[arg(long, default_value_t = 300)]
1173 stale_secs: u64,
1174 #[arg(long)]
1175 json: bool,
1176 },
1177 Env {
1181 name: Option<String>,
1183 #[arg(long)]
1184 json: bool,
1185 },
1186 Current {
1190 #[arg(long)]
1191 json: bool,
1192 },
1193 Bind {
1201 name: Option<String>,
1205 #[arg(long)]
1206 json: bool,
1207 },
1208 Destroy {
1212 name: String,
1213 #[arg(long)]
1215 force: bool,
1216 #[arg(long)]
1217 json: bool,
1218 },
1219}
1220
1221#[derive(Subcommand, Debug)]
1226pub enum MeshCommand {
1227 Status {
1230 #[arg(long, default_value_t = 300)]
1232 stale_secs: u64,
1233 #[arg(long)]
1234 json: bool,
1235 },
1236 Broadcast {
1255 #[arg(long, default_value = "claim")]
1258 kind: String,
1259 #[arg(long, default_value = "local")]
1261 scope: String,
1262 #[arg(long)]
1264 exclude: Vec<String>,
1265 #[arg(long)]
1269 noreply: bool,
1270 body: String,
1272 #[arg(long)]
1273 json: bool,
1274 },
1275 Role {
1284 #[command(subcommand)]
1285 action: MeshRoleAction,
1286 },
1287 Route {
1303 role: String,
1305 #[arg(long, default_value = "round-robin")]
1307 strategy: String,
1308 #[arg(long)]
1310 exclude: Vec<String>,
1311 #[arg(long, default_value = "claim")]
1314 kind: String,
1315 body: String,
1317 #[arg(long)]
1318 json: bool,
1319 },
1320}
1321
1322#[derive(Subcommand, Debug)]
1324pub enum MeshRoleAction {
1325 Set {
1330 role: String,
1331 #[arg(long)]
1332 json: bool,
1333 },
1334 Get {
1337 peer: Option<String>,
1338 #[arg(long)]
1339 json: bool,
1340 },
1341 List {
1344 #[arg(long)]
1345 json: bool,
1346 },
1347 Clear {
1350 #[arg(long)]
1351 json: bool,
1352 },
1353}
1354
1355#[derive(Subcommand, Debug)]
1356pub enum ServiceAction {
1357 Install {
1367 #[arg(long)]
1369 local_relay: bool,
1370 #[arg(long)]
1371 json: bool,
1372 },
1373 Uninstall {
1377 #[arg(long)]
1379 local_relay: bool,
1380 #[arg(long)]
1381 json: bool,
1382 },
1383 Status {
1385 #[arg(long)]
1387 local_relay: bool,
1388 #[arg(long)]
1389 json: bool,
1390 },
1391}
1392
1393#[derive(Subcommand, Debug)]
1394pub enum ResponderCommand {
1395 Set {
1397 status: String,
1399 #[arg(long)]
1401 reason: Option<String>,
1402 #[arg(long)]
1404 json: bool,
1405 },
1406 Get {
1408 peer: Option<String>,
1410 #[arg(long)]
1412 json: bool,
1413 },
1414}
1415
1416#[derive(Subcommand, Debug)]
1417pub enum ProfileAction {
1418 Set {
1422 field: String,
1423 value: String,
1424 #[arg(long)]
1425 json: bool,
1426 },
1427 Get {
1429 #[arg(long)]
1430 json: bool,
1431 },
1432 Clear {
1434 field: String,
1435 #[arg(long)]
1436 json: bool,
1437 },
1438}
1439
1440pub fn run() -> Result<()> {
1442 crate::session::maybe_adopt_session_wire_home("cli");
1453 let cli = Cli::parse();
1454 match cli.command {
1455 Command::Init {
1456 handle,
1457 name,
1458 relay,
1459 offline,
1460 json,
1461 } => cmd_init(
1462 Some(&handle),
1463 name.as_deref(),
1464 relay.as_deref(),
1465 offline,
1466 json,
1467 ),
1468 Command::Status { peer, json } => {
1469 if let Some(peer) = peer {
1470 cmd_status_peer(&peer, json)
1471 } else {
1472 cmd_status(json)
1473 }
1474 }
1475 Command::Whoami {
1476 json,
1477 short,
1478 colored,
1479 } => cmd_whoami(json_default(json), short, colored),
1480 Command::Peers { json } => cmd_peers(json_default(json)),
1481 Command::Here { json } => cmd_here(json_default(json)),
1482 Command::Completions { shell } => {
1483 use clap::CommandFactory;
1490 let mut cmd = Cli::command();
1491 clap_complete::generate(shell, &mut cmd, "wire", &mut std::io::stdout());
1492 Ok(())
1493 }
1494 Command::Pending { json } => cmd_pair_list_inbound(json_default(json)),
1495 Command::Reject { peer, json } => cmd_pair_reject(&peer, json_default(json)),
1496 Command::Send {
1497 peer,
1498 kind_or_body,
1499 body,
1500 deadline,
1501 no_auto_pair,
1502 json,
1503 } => {
1504 let (kind, body) = match body {
1507 Some(real_body) => (kind_or_body, real_body),
1508 None => ("claim".to_string(), kind_or_body),
1509 };
1510 cmd_send(
1511 &peer,
1512 &kind,
1513 &body,
1514 deadline.as_deref(),
1515 no_auto_pair,
1516 json_default(json),
1517 )
1518 }
1519 Command::Dial {
1520 name,
1521 message,
1522 json,
1523 } => cmd_dial(&name, message.as_deref(), json_default(json)),
1524 Command::Tail { peer, json, limit } => cmd_tail(peer.as_deref(), json, limit),
1525 Command::Monitor {
1526 peer,
1527 json,
1528 include_handshake,
1529 interval_ms,
1530 replay,
1531 } => cmd_monitor(
1532 peer.as_deref(),
1533 json,
1534 include_handshake,
1535 interval_ms,
1536 replay,
1537 ),
1538 Command::Verify { path, json } => cmd_verify(&path, json),
1539 Command::Responder { command } => match command {
1540 ResponderCommand::Set {
1541 status,
1542 reason,
1543 json,
1544 } => cmd_responder_set(&status, reason.as_deref(), json),
1545 ResponderCommand::Get { peer, json } => cmd_responder_get(peer.as_deref(), json),
1546 },
1547 Command::Mcp => cmd_mcp(),
1548 Command::RelayServer {
1549 bind,
1550 local_only,
1551 uds,
1552 } => cmd_relay_server(&bind, local_only, uds.as_deref()),
1553 Command::BindRelay {
1554 url,
1555 scope,
1556 replace,
1557 migrate_pinned,
1558 json,
1559 } => cmd_bind_relay(&url, scope.as_deref(), replace, migrate_pinned, json),
1560 Command::AddPeerSlot {
1561 handle,
1562 url,
1563 slot_id,
1564 slot_token,
1565 json,
1566 } => cmd_add_peer_slot(&handle, &url, &slot_id, &slot_token, json),
1567 Command::Push { peer, json } => cmd_push(peer.as_deref(), json),
1568 Command::Pull { json } => cmd_pull(json),
1569 Command::Pin { card_file, json } => cmd_pin(&card_file, json),
1570 Command::RotateSlot { no_announce, json } => cmd_rotate_slot(no_announce, json),
1571 Command::ForgetPeer {
1572 handle,
1573 purge,
1574 json,
1575 } => cmd_forget_peer(&handle, purge, json),
1576 Command::Daemon {
1577 interval,
1578 once,
1579 json,
1580 } => cmd_daemon(interval, once, json),
1581 Command::PairHost {
1582 relay,
1583 yes,
1584 timeout,
1585 detach,
1586 json,
1587 } => {
1588 if detach {
1589 cmd_pair_host_detach(&relay, json)
1590 } else {
1591 cmd_pair_host(&relay, yes, timeout)
1592 }
1593 }
1594 Command::PairJoin {
1595 code_phrase,
1596 relay,
1597 yes,
1598 timeout,
1599 detach,
1600 json,
1601 } => {
1602 if detach {
1603 cmd_pair_join_detach(&code_phrase, &relay, json)
1604 } else {
1605 cmd_pair_join(&code_phrase, &relay, yes, timeout)
1606 }
1607 }
1608 Command::PairConfirm {
1609 code_phrase,
1610 digits,
1611 json,
1612 } => cmd_pair_confirm(&code_phrase, &digits, json),
1613 Command::PairList {
1614 json,
1615 watch,
1616 watch_interval,
1617 } => cmd_pair_list(json, watch, watch_interval),
1618 Command::PairCancel { code_phrase, json } => cmd_pair_cancel(&code_phrase, json),
1619 Command::PairWatch {
1620 code_phrase,
1621 status,
1622 timeout,
1623 json,
1624 } => cmd_pair_watch(&code_phrase, &status, timeout, json),
1625 Command::Pair {
1626 handle,
1627 code,
1628 relay,
1629 yes,
1630 timeout,
1631 no_setup,
1632 detach,
1633 } => {
1634 if handle.contains('@') && code.is_none() {
1641 cmd_pair_megacommand(&handle, Some(&relay), timeout, false)
1642 } else if detach {
1643 cmd_pair_detach(&handle, code.as_deref(), &relay)
1644 } else {
1645 cmd_pair(&handle, code.as_deref(), &relay, yes, timeout, no_setup)
1646 }
1647 }
1648 Command::PairAbandon { code_phrase, relay } => cmd_pair_abandon(&code_phrase, &relay),
1649 Command::PairAccept { peer, json } => {
1650 let j = json_default(json);
1651 deprecation_warn("pair-accept", &format!("accept {peer}"), j);
1652 cmd_pair_accept(&peer, j)
1653 }
1654 Command::PairReject { peer, json } => {
1655 let j = json_default(json);
1656 deprecation_warn("pair-reject", &format!("reject {peer}"), j);
1657 cmd_pair_reject(&peer, j)
1658 }
1659 Command::PairListInbound { json } => {
1660 let j = json_default(json);
1661 deprecation_warn("pair-list-inbound", "pending", j);
1662 cmd_pair_list_inbound(j)
1663 }
1664 Command::Session(cmd) => cmd_session(cmd),
1665 Command::Identity { cmd } => cmd_identity(cmd),
1666 Command::Mesh(cmd) => cmd_mesh(cmd),
1667 Command::Invite {
1668 relay,
1669 ttl,
1670 uses,
1671 share,
1672 json,
1673 } => cmd_invite(&relay, ttl, uses, share, json),
1674 Command::Accept { target, json } => {
1675 let j = json_default(json);
1681 if target.starts_with("wire://pair?") {
1682 deprecation_warn("accept-url", "accept-invite <url>", j);
1683 cmd_accept(&target, j)
1684 } else {
1685 cmd_pair_accept(&target, j)
1686 }
1687 }
1688 Command::AcceptInvite { url, json } => cmd_accept(&url, json_default(json)),
1689 Command::Whois {
1690 handle,
1691 json,
1692 relay,
1693 } => {
1694 match handle.as_deref() {
1703 Some(h) if !h.contains('@') => cmd_whois_local(h, json),
1704 other => cmd_whois(other, json, relay.as_deref()),
1705 }
1706 }
1707 Command::Add {
1708 handle,
1709 relay,
1710 local_sister,
1711 json,
1712 } => cmd_add(&handle, relay.as_deref(), local_sister, json),
1713 Command::Up {
1714 relay,
1715 name,
1716 with_local,
1717 no_local,
1718 json,
1719 } => cmd_up(
1720 relay.as_deref(),
1721 name.as_deref(),
1722 with_local.as_deref(),
1723 no_local,
1724 json,
1725 ),
1726 Command::Doctor {
1727 json,
1728 recent_rejections,
1729 } => cmd_doctor(json, recent_rejections),
1730 Command::Upgrade { check, json } => cmd_upgrade(check, json),
1731 Command::Service { action } => cmd_service(action),
1732 Command::Diag { action } => cmd_diag(action),
1733 Command::Claim {
1734 nick,
1735 relay,
1736 public_url,
1737 hidden,
1738 json,
1739 } => cmd_claim(&nick, relay.as_deref(), public_url.as_deref(), hidden, json),
1740 Command::Profile { action } => cmd_profile(action),
1741 Command::Setup { apply } => cmd_setup(apply),
1742 Command::Reactor {
1743 on_event,
1744 peer,
1745 kind,
1746 verified_only,
1747 interval,
1748 once,
1749 dry_run,
1750 max_per_minute,
1751 max_chain_depth,
1752 } => cmd_reactor(
1753 &on_event,
1754 peer.as_deref(),
1755 kind.as_deref(),
1756 verified_only,
1757 interval,
1758 once,
1759 dry_run,
1760 max_per_minute,
1761 max_chain_depth,
1762 ),
1763 Command::Notify {
1764 interval,
1765 peer,
1766 once,
1767 json,
1768 } => cmd_notify(interval, peer.as_deref(), once, json),
1769 }
1770}
1771
1772fn cmd_init(
1775 handle: Option<&str>,
1776 name: Option<&str>,
1777 relay: Option<&str>,
1778 offline: bool,
1779 as_json: bool,
1780) -> Result<()> {
1781 if let Some(h) = handle
1787 && !h
1788 .chars()
1789 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
1790 {
1791 bail!("handle must be ASCII alphanumeric / '-' / '_' (got {h:?})");
1792 }
1793 if config::is_initialized()? {
1794 bail!(
1795 "already initialized — config exists at {:?}. Delete it first if you want a fresh identity.",
1796 config::config_dir()?
1797 );
1798 }
1799 let mut resolved_relay: Option<String> = relay.map(str::to_string);
1813 if resolved_relay.is_none() && !offline {
1814 let default_local = "http://127.0.0.1:8771";
1815 let client = crate::relay_client::RelayClient::new(default_local);
1816 if client.check_healthz().is_ok() {
1817 eprintln!(
1818 "wire init: local relay at {default_local} reachable — auto-attaching. \
1819 Use --relay <url> to pick a different relay, --offline to skip."
1820 );
1821 resolved_relay = Some(default_local.to_string());
1822 } else {
1823 use std::io::{BufRead, IsTerminal, Write};
1829 let interactive = std::io::stdin().is_terminal() && std::io::stderr().is_terminal();
1830 if interactive && std::env::var("WIRE_NO_INTERACTIVE").is_err() {
1831 eprintln!("wire init: no local relay reachable at {default_local}.");
1832 eprint!(
1833 " Bind to public federation relay https://wireup.net instead? \
1834 [Y/n/offline/url]: "
1835 );
1836 let _ = std::io::stderr().flush();
1837 let mut input = String::new();
1838 let _ = std::io::stdin().lock().read_line(&mut input);
1839 let answer = input.trim();
1840 match answer {
1841 "" | "y" | "Y" | "yes" | "YES" => {
1842 eprintln!("wire init: binding to https://wireup.net");
1843 resolved_relay = Some("https://wireup.net".to_string());
1844 }
1845 "n" | "N" | "no" | "NO" => {
1846 bail!(
1847 "wire init: declined federation default; re-run with --relay <url> or --offline."
1848 );
1849 }
1850 "offline" | "OFFLINE" => {
1851 eprintln!(
1852 "wire init: proceeding offline. \
1853 Run `wire bind-relay <url>` before pairing."
1854 );
1855 }
1861 url if url.starts_with("http://") || url.starts_with("https://") => {
1862 eprintln!("wire init: binding to {url}");
1863 resolved_relay = Some(url.to_string());
1864 }
1865 other => {
1866 bail!(
1867 "wire init: unrecognized answer `{other}` — \
1868 expected Y/n/offline/<url>. Re-run with --relay or --offline."
1869 );
1870 }
1871 }
1872 } else {
1873 bail!(
1874 "wire init: no relay specified and no local relay reachable at \
1875 http://127.0.0.1:8771.\n\
1876 Pick one (or just run `wire up`):\n\
1877 • `wire service install --local-relay` — start the local relay, then re-run\n\
1878 • `wire up @wireup.net` — bind to public federation in one command\n\
1879 • `wire init --offline` — generate keypair only \
1880 (peers cannot reach you until you `wire bind-relay <url>` later)"
1881 );
1882 }
1883 }
1884 }
1885 let relay = resolved_relay.as_deref();
1886
1887 config::ensure_dirs()?;
1888 let (sk_seed, pk_bytes) = generate_keypair();
1889 config::write_private_key(&sk_seed)?;
1890
1891 let seed = handle.unwrap_or("agent");
1909 let synth_did = crate::agent_card::did_for_with_key(seed, &pk_bytes);
1910 let character = crate::character::Character::from_did(&synth_did);
1911 let canonical_handle: &str = &character.nickname;
1912 if let Some(typed) = handle
1913 && typed != canonical_handle
1914 {
1915 eprintln!(
1916 "wire init: one-name rule — typed `{typed}` ignored in favor of \
1917 DID-derived persona `{canonical_handle}`. Peers will reach you as `{canonical_handle}`."
1918 );
1919 }
1920
1921 let card = build_agent_card(canonical_handle, &pk_bytes, name, None, None);
1922 let signed = sign_agent_card(&card, &sk_seed);
1923 config::write_agent_card(&signed)?;
1924
1925 let mut trust = empty_trust();
1926 add_self_to_trust(&mut trust, canonical_handle, &pk_bytes);
1927 config::write_trust(&trust)?;
1928
1929 let fp = fingerprint(&pk_bytes);
1930 let key_id = make_key_id(canonical_handle, &pk_bytes);
1931 let handle = canonical_handle;
1934
1935 let mut relay_info: Option<(String, String)> = None;
1937 if let Some(url) = relay {
1938 let normalized = url.trim_end_matches('/');
1939 let client = crate::relay_client::RelayClient::new(normalized);
1940 client.check_healthz()?;
1941 let alloc = client.allocate_slot(Some(handle))?;
1942 let mut state = config::read_relay_state()?;
1943 state["self"] = json!({
1944 "relay_url": normalized,
1945 "slot_id": alloc.slot_id.clone(),
1946 "slot_token": alloc.slot_token,
1947 });
1948 config::write_relay_state(&state)?;
1949 relay_info = Some((normalized.to_string(), alloc.slot_id));
1950 }
1951
1952 let did_str = crate::agent_card::did_for_with_key(handle, &pk_bytes);
1953 if as_json {
1954 let mut out = json!({
1955 "did": did_str.clone(),
1956 "fingerprint": fp,
1957 "key_id": key_id,
1958 "config_dir": config::config_dir()?.to_string_lossy(),
1959 });
1960 if let Some((url, slot_id)) = &relay_info {
1961 out["relay_url"] = json!(url);
1962 out["slot_id"] = json!(slot_id);
1963 }
1964 println!("{}", serde_json::to_string(&out)?);
1965 } else {
1966 println!("generated {did_str} (ed25519:{key_id})");
1967 println!(
1968 "config written to {}",
1969 config::config_dir()?.to_string_lossy()
1970 );
1971 if let Some((url, slot_id)) = &relay_info {
1972 println!("bound to relay {url} (slot {slot_id})");
1973 println!();
1974 println!(
1975 "next step: `wire pair-host --relay {url}` to print a code phrase for a peer."
1976 );
1977 } else {
1978 println!();
1979 println!(
1980 "next step: `wire pair-host --relay <url>` to bind a relay + open a pair-slot."
1981 );
1982 }
1983 }
1984 Ok(())
1985}
1986
1987fn cmd_status(as_json: bool) -> Result<()> {
1990 let initialized = config::is_initialized()?;
1991
1992 let mut summary = json!({
1993 "initialized": initialized,
1994 });
1995
1996 if initialized {
1997 let card = config::read_agent_card()?;
1998 let did = card
1999 .get("did")
2000 .and_then(Value::as_str)
2001 .unwrap_or("")
2002 .to_string();
2003 let handle = card
2007 .get("handle")
2008 .and_then(Value::as_str)
2009 .map(str::to_string)
2010 .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
2011 let pk_b64 = card
2012 .get("verify_keys")
2013 .and_then(Value::as_object)
2014 .and_then(|m| m.values().next())
2015 .and_then(|v| v.get("key"))
2016 .and_then(Value::as_str)
2017 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
2018 let pk_bytes = crate::signing::b64decode(pk_b64)?;
2019 summary["did"] = json!(did);
2020 summary["handle"] = json!(handle);
2021 summary["fingerprint"] = json!(fingerprint(&pk_bytes));
2022 summary["capabilities"] = card
2023 .get("capabilities")
2024 .cloned()
2025 .unwrap_or_else(|| json!([]));
2026
2027 let trust = config::read_trust()?;
2028 let relay_state_for_tier =
2029 config::read_relay_state().unwrap_or_else(|_| json!({"peers": {}}));
2030 let mut peers = Vec::new();
2031 if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
2032 for (peer_handle, _agent) in agents {
2033 if peer_handle == &handle {
2034 continue; }
2036 peers.push(json!({
2041 "handle": peer_handle,
2042 "tier": effective_peer_tier(&trust, &relay_state_for_tier, peer_handle),
2043 }));
2044 }
2045 }
2046 summary["peers"] = json!(peers);
2047
2048 let relay_state = config::read_relay_state()?;
2049 summary["self_relay"] = relay_state.get("self").cloned().unwrap_or(Value::Null);
2050 if !summary["self_relay"].is_null() {
2051 if let Some(obj) = summary["self_relay"].as_object_mut() {
2053 obj.remove("slot_token");
2054 }
2055 }
2056 summary["peer_slots_count"] = json!(
2057 relay_state
2058 .get("peers")
2059 .and_then(Value::as_object)
2060 .map(|m| m.len())
2061 .unwrap_or(0)
2062 );
2063
2064 let outbox = config::outbox_dir()?;
2066 let inbox = config::inbox_dir()?;
2067 summary["outbox"] = json!(scan_jsonl_dir(&outbox)?);
2068 summary["inbox"] = json!(scan_jsonl_dir(&inbox)?);
2069
2070 let snap = crate::ensure_up::daemon_liveness();
2076 let mut daemon = json!({
2077 "running": snap.pidfile_alive,
2078 "pid": snap.pidfile_pid,
2079 "all_running_pids": snap.pgrep_pids,
2080 "orphans": snap.orphan_pids,
2081 });
2082 if let crate::ensure_up::PidRecord::Json(d) = &snap.record {
2083 daemon["version"] = json!(d.version);
2084 daemon["bin_path"] = json!(d.bin_path);
2085 daemon["did"] = json!(d.did);
2086 daemon["relay_url"] = json!(d.relay_url);
2087 daemon["started_at"] = json!(d.started_at);
2088 daemon["schema"] = json!(d.schema);
2089 if d.version != env!("CARGO_PKG_VERSION") {
2090 daemon["version_mismatch"] = json!({
2091 "daemon": d.version.clone(),
2092 "cli": env!("CARGO_PKG_VERSION"),
2093 });
2094 }
2095 } else if matches!(snap.record, crate::ensure_up::PidRecord::LegacyInt(_)) {
2096 daemon["pidfile_form"] = json!("legacy-int");
2097 daemon["version_mismatch"] = json!({
2098 "daemon": "<pre-0.5.11>",
2099 "cli": env!("CARGO_PKG_VERSION"),
2100 });
2101 }
2102 summary["daemon"] = daemon;
2103
2104 let pending = crate::pending_pair::list_pending().unwrap_or_default();
2106 let mut counts: std::collections::BTreeMap<String, u32> = Default::default();
2107 for p in &pending {
2108 *counts.entry(p.status.clone()).or_default() += 1;
2109 }
2110 let pending_inbound =
2112 crate::pending_inbound_pair::list_pending_inbound().unwrap_or_default();
2113 let inbound_handles: Vec<&str> = pending_inbound
2114 .iter()
2115 .map(|p| p.peer_handle.as_str())
2116 .collect();
2117 summary["pending_pairs"] = json!({
2118 "total": pending.len(),
2119 "by_status": counts,
2120 "inbound_count": pending_inbound.len(),
2121 "inbound_handles": inbound_handles,
2122 });
2123 }
2124
2125 if as_json {
2126 println!("{}", serde_json::to_string(&summary)?);
2127 } else if !initialized {
2128 println!("not initialized — run `wire init <handle>` first");
2129 } else {
2130 println!("did: {}", summary["did"].as_str().unwrap_or("?"));
2131 println!(
2132 "fingerprint: {}",
2133 summary["fingerprint"].as_str().unwrap_or("?")
2134 );
2135 println!("capabilities: {}", summary["capabilities"]);
2136 if !summary["self_relay"].is_null() {
2137 println!(
2138 "self relay: {} (slot {})",
2139 summary["self_relay"]["relay_url"].as_str().unwrap_or("?"),
2140 summary["self_relay"]["slot_id"].as_str().unwrap_or("?")
2141 );
2142 } else {
2143 println!("self relay: (not bound — run `wire pair-host --relay <url>` to bind)");
2144 }
2145 println!(
2146 "peers: {}",
2147 summary["peers"].as_array().map(|a| a.len()).unwrap_or(0)
2148 );
2149 for p in summary["peers"].as_array().unwrap_or(&Vec::new()) {
2150 println!(
2151 " - {:<20} tier={}",
2152 p["handle"].as_str().unwrap_or(""),
2153 p["tier"].as_str().unwrap_or("?")
2154 );
2155 }
2156 println!(
2157 "outbox: {} file(s), {} event(s) queued",
2158 summary["outbox"]["files"].as_u64().unwrap_or(0),
2159 summary["outbox"]["events"].as_u64().unwrap_or(0)
2160 );
2161 println!(
2162 "inbox: {} file(s), {} event(s) received",
2163 summary["inbox"]["files"].as_u64().unwrap_or(0),
2164 summary["inbox"]["events"].as_u64().unwrap_or(0)
2165 );
2166 let daemon_running = summary["daemon"]["running"].as_bool().unwrap_or(false);
2167 let daemon_pid = summary["daemon"]["pid"]
2168 .as_u64()
2169 .map(|p| p.to_string())
2170 .unwrap_or_else(|| "—".to_string());
2171 let daemon_version = summary["daemon"]["version"].as_str().unwrap_or("");
2172 let version_suffix = if !daemon_version.is_empty() {
2173 format!(" v{daemon_version}")
2174 } else {
2175 String::new()
2176 };
2177 println!(
2178 "daemon: {} (pid {}{})",
2179 if daemon_running { "running" } else { "DOWN" },
2180 daemon_pid,
2181 version_suffix,
2182 );
2183 if let Some(mm) = summary["daemon"].get("version_mismatch") {
2185 println!(
2186 " !! version mismatch: daemon={} CLI={}. \
2187 run `wire upgrade` to swap atomically.",
2188 mm["daemon"].as_str().unwrap_or("?"),
2189 mm["cli"].as_str().unwrap_or("?"),
2190 );
2191 }
2192 if let Some(orphans) = summary["daemon"]["orphans"].as_array()
2193 && !orphans.is_empty()
2194 {
2195 let pids: Vec<String> = orphans
2196 .iter()
2197 .filter_map(|v| v.as_u64().map(|p| p.to_string()))
2198 .collect();
2199 println!(
2200 " !! orphan daemon process(es): pids {}. \
2201 pgrep saw them but pidfile didn't — likely stale process from \
2202 prior install. Multiple daemons race the relay cursor.",
2203 pids.join(", ")
2204 );
2205 }
2206 let pending_total = summary["pending_pairs"]["total"].as_u64().unwrap_or(0);
2207 let inbound_count = summary["pending_pairs"]["inbound_count"]
2208 .as_u64()
2209 .unwrap_or(0);
2210 if pending_total > 0 {
2211 print!("pending pairs: {pending_total}");
2212 if let Some(obj) = summary["pending_pairs"]["by_status"].as_object() {
2213 let parts: Vec<String> = obj
2214 .iter()
2215 .map(|(k, v)| format!("{}={}", k, v.as_u64().unwrap_or(0)))
2216 .collect();
2217 if !parts.is_empty() {
2218 print!(" ({})", parts.join(", "));
2219 }
2220 }
2221 println!();
2222 } else if inbound_count == 0 {
2223 println!("pending pairs: none");
2224 }
2225 if inbound_count > 0 {
2229 let handles: Vec<String> = summary["pending_pairs"]["inbound_handles"]
2230 .as_array()
2231 .map(|a| {
2232 a.iter()
2233 .filter_map(|v| v.as_str().map(str::to_string))
2234 .collect()
2235 })
2236 .unwrap_or_default();
2237 println!(
2238 "inbound pair requests ({inbound_count}): {} — `wire pair-list` to inspect, `wire pair-accept <peer>` to accept, `wire pair-reject <peer>` to refuse",
2239 handles.join(", "),
2240 );
2241 }
2242 }
2243 Ok(())
2244}
2245
2246fn scan_jsonl_dir(dir: &std::path::Path) -> Result<Value> {
2247 if !dir.exists() {
2248 return Ok(json!({"files": 0, "events": 0}));
2249 }
2250 let mut files = 0usize;
2251 let mut events = 0usize;
2252 for entry in std::fs::read_dir(dir)? {
2253 let path = entry?.path();
2254 if path.extension().map(|x| x == "jsonl").unwrap_or(false) {
2255 files += 1;
2256 if let Ok(body) = std::fs::read_to_string(&path) {
2257 events += body.lines().filter(|l| !l.trim().is_empty()).count();
2258 }
2259 }
2260 }
2261 Ok(json!({"files": files, "events": events}))
2262}
2263
2264fn responder_status_allowed(status: &str) -> bool {
2267 matches!(
2268 status,
2269 "online" | "offline" | "oauth_locked" | "rate_limited" | "degraded"
2270 )
2271}
2272
2273fn relay_slot_for(peer: Option<&str>) -> Result<(String, String, String, String)> {
2274 let state = config::read_relay_state()?;
2275 let (label, slot_info) = match peer {
2276 Some(peer) => (
2277 peer.to_string(),
2278 state
2279 .get("peers")
2280 .and_then(|p| p.get(peer))
2281 .ok_or_else(|| {
2282 anyhow!(
2283 "unknown peer {peer:?} in relay state — pair with them first:\n \
2284 wire add {peer}@wireup.net (or {peer}@<their-relay>)\n\
2285 (`wire peers` lists who you've already paired with.)"
2286 )
2287 })?,
2288 ),
2289 None => (
2290 "self".to_string(),
2291 state.get("self").filter(|v| !v.is_null()).ok_or_else(|| {
2292 anyhow!("self slot not bound — run `wire bind-relay <url>` first")
2293 })?,
2294 ),
2295 };
2296 let relay_url = slot_info["relay_url"]
2297 .as_str()
2298 .ok_or_else(|| anyhow!("{label} relay_url missing"))?
2299 .to_string();
2300 let slot_id = slot_info["slot_id"]
2301 .as_str()
2302 .ok_or_else(|| anyhow!("{label} slot_id missing"))?
2303 .to_string();
2304 let slot_token = slot_info["slot_token"]
2305 .as_str()
2306 .ok_or_else(|| anyhow!("{label} slot_token missing"))?
2307 .to_string();
2308 Ok((label, relay_url, slot_id, slot_token))
2309}
2310
2311fn cmd_responder_set(status: &str, reason: Option<&str>, as_json: bool) -> Result<()> {
2312 if !responder_status_allowed(status) {
2313 bail!("status must be one of: online, offline, oauth_locked, rate_limited, degraded");
2314 }
2315 let (_label, relay_url, slot_id, slot_token) = relay_slot_for(None)?;
2316 let now = time::OffsetDateTime::now_utc()
2317 .format(&time::format_description::well_known::Rfc3339)
2318 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
2319 let mut record = json!({
2320 "status": status,
2321 "set_at": now,
2322 });
2323 if let Some(reason) = reason {
2324 record["reason"] = json!(reason);
2325 }
2326 if status == "online" {
2327 record["last_success_at"] = json!(now);
2328 }
2329 let client = crate::relay_client::RelayClient::new(&relay_url);
2330 let saved = client.responder_health_set(&slot_id, &slot_token, &record)?;
2331 if as_json {
2332 println!("{}", serde_json::to_string(&saved)?);
2333 } else {
2334 let reason = saved
2335 .get("reason")
2336 .and_then(Value::as_str)
2337 .map(|r| format!(" — {r}"))
2338 .unwrap_or_default();
2339 println!(
2340 "responder {}{}",
2341 saved
2342 .get("status")
2343 .and_then(Value::as_str)
2344 .unwrap_or(status),
2345 reason
2346 );
2347 }
2348 Ok(())
2349}
2350
2351fn cmd_responder_get(peer: Option<&str>, as_json: bool) -> Result<()> {
2352 let (label, relay_url, slot_id, slot_token) = relay_slot_for(peer)?;
2353 let client = crate::relay_client::RelayClient::new(&relay_url);
2354 let health = client.responder_health_get(&slot_id, &slot_token)?;
2355 if as_json {
2356 println!(
2357 "{}",
2358 serde_json::to_string(&json!({
2359 "target": label,
2360 "responder_health": health,
2361 }))?
2362 );
2363 } else if health.is_null() {
2364 println!("{label}: responder health not reported");
2365 } else {
2366 let status = health
2367 .get("status")
2368 .and_then(Value::as_str)
2369 .unwrap_or("unknown");
2370 let reason = health
2371 .get("reason")
2372 .and_then(Value::as_str)
2373 .map(|r| format!(" — {r}"))
2374 .unwrap_or_default();
2375 let last_success = health
2376 .get("last_success_at")
2377 .and_then(Value::as_str)
2378 .map(|t| format!(" (last_success: {t})"))
2379 .unwrap_or_default();
2380 println!("{label}: {status}{reason}{last_success}");
2381 }
2382 Ok(())
2383}
2384
2385fn cmd_status_peer(peer: &str, as_json: bool) -> Result<()> {
2386 let (_label, relay_url, slot_id, slot_token) = relay_slot_for(Some(peer))?;
2387 let client = crate::relay_client::RelayClient::new(&relay_url);
2388
2389 let started = std::time::Instant::now();
2390 let transport_ok = client.healthz().unwrap_or(false);
2391 let latency_ms = started.elapsed().as_millis() as u64;
2392
2393 let (event_count, last_pull_at_unix) = client.slot_state(&slot_id, &slot_token)?;
2394 let now = std::time::SystemTime::now()
2395 .duration_since(std::time::UNIX_EPOCH)
2396 .map(|d| d.as_secs())
2397 .unwrap_or(0);
2398 let attention = match last_pull_at_unix {
2399 Some(last) if now.saturating_sub(last) <= 300 => json!({
2400 "status": "ok",
2401 "last_pull_at_unix": last,
2402 "age_seconds": now.saturating_sub(last),
2403 "event_count": event_count,
2404 }),
2405 Some(last) => json!({
2406 "status": "stale",
2407 "last_pull_at_unix": last,
2408 "age_seconds": now.saturating_sub(last),
2409 "event_count": event_count,
2410 }),
2411 None => json!({
2412 "status": "never_pulled",
2413 "last_pull_at_unix": Value::Null,
2414 "event_count": event_count,
2415 }),
2416 };
2417
2418 let responder_health = client.responder_health_get(&slot_id, &slot_token)?;
2419 let responder = if responder_health.is_null() {
2420 json!({"status": "not_reported", "record": Value::Null})
2421 } else {
2422 json!({
2423 "status": responder_health
2424 .get("status")
2425 .and_then(Value::as_str)
2426 .unwrap_or("unknown"),
2427 "record": responder_health,
2428 })
2429 };
2430
2431 let report = json!({
2432 "peer": peer,
2433 "transport": {
2434 "status": if transport_ok { "ok" } else { "error" },
2435 "relay_url": relay_url,
2436 "latency_ms": latency_ms,
2437 },
2438 "attention": attention,
2439 "responder": responder,
2440 });
2441
2442 if as_json {
2443 println!("{}", serde_json::to_string(&report)?);
2444 } else {
2445 let transport_line = if transport_ok {
2446 format!("ok relay reachable ({latency_ms}ms)")
2447 } else {
2448 "error relay unreachable".to_string()
2449 };
2450 println!("transport {transport_line}");
2451 match report["attention"]["status"].as_str().unwrap_or("unknown") {
2452 "ok" => println!(
2453 "attention ok last pull {}s ago",
2454 report["attention"]["age_seconds"].as_u64().unwrap_or(0)
2455 ),
2456 "stale" => println!(
2457 "attention stale last pull {}m ago",
2458 report["attention"]["age_seconds"].as_u64().unwrap_or(0) / 60
2459 ),
2460 "never_pulled" => println!("attention never pulled since relay reset"),
2461 other => println!("attention {other}"),
2462 }
2463 if report["responder"]["status"] == "not_reported" {
2464 println!("auto-responder not reported");
2465 } else {
2466 let record = &report["responder"]["record"];
2467 let status = record
2468 .get("status")
2469 .and_then(Value::as_str)
2470 .unwrap_or("unknown");
2471 let reason = record
2472 .get("reason")
2473 .and_then(Value::as_str)
2474 .map(|r| format!(" — {r}"))
2475 .unwrap_or_default();
2476 println!("auto-responder {status}{reason}");
2477 }
2478 }
2479 Ok(())
2480}
2481
2482fn current_cwd_display() -> String {
2490 let cwd = match std::env::current_dir() {
2491 Ok(c) => c,
2492 Err(_) => return String::from("?"),
2493 };
2494 if let Some(home) = dirs::home_dir()
2495 && let Ok(rel) = cwd.strip_prefix(&home)
2496 {
2497 let rel_str = rel.to_string_lossy();
2499 if rel_str.is_empty() {
2500 return String::from("~");
2501 }
2502 return format!("~/{}", rel_str);
2503 }
2504 cwd.to_string_lossy().into_owned()
2505}
2506
2507fn cmd_whoami(as_json: bool, short: bool, colored: bool) -> Result<()> {
2508 if !config::is_initialized()? {
2509 bail!("not initialized — run `wire init <handle>` first");
2510 }
2511 let card = config::read_agent_card()?;
2512 let did = card
2513 .get("did")
2514 .and_then(Value::as_str)
2515 .unwrap_or("")
2516 .to_string();
2517 let handle = card
2518 .get("handle")
2519 .and_then(Value::as_str)
2520 .map(str::to_string)
2521 .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
2522 let character = crate::character::Character::from_did(&did);
2526
2527 let cwd_display = current_cwd_display();
2533
2534 if short {
2537 println!("{} · {}", character.short(), cwd_display);
2538 return Ok(());
2539 }
2540 if colored {
2541 println!("{} \x1b[2m·\x1b[0m {}", character.colored(), cwd_display);
2542 return Ok(());
2543 }
2544
2545 let pk_b64 = card
2546 .get("verify_keys")
2547 .and_then(Value::as_object)
2548 .and_then(|m| m.values().next())
2549 .and_then(|v| v.get("key"))
2550 .and_then(Value::as_str)
2551 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
2552 let pk_bytes = crate::signing::b64decode(pk_b64)?;
2553 let fp = fingerprint(&pk_bytes);
2554 let key_id = make_key_id(&handle, &pk_bytes);
2555 let capabilities = card
2556 .get("capabilities")
2557 .cloned()
2558 .unwrap_or_else(|| json!(["wire/v3.1"]));
2559
2560 if as_json {
2561 let has_override = false;
2565 println!(
2566 "{}",
2567 serde_json::to_string(&json!({
2568 "did": did,
2569 "handle": handle,
2570 "fingerprint": fp,
2571 "key_id": key_id,
2572 "public_key_b64": pk_b64,
2573 "capabilities": capabilities,
2574 "config_dir": config::config_dir()?.to_string_lossy(),
2575 "persona": character,
2576 "persona_override": has_override,
2577 }))?
2578 );
2579 } else {
2580 println!("{}", character.colored());
2581 println!("{did} (ed25519:{key_id})");
2582 println!("fingerprint: {fp}");
2583 println!("capabilities: {capabilities}");
2584 }
2585 Ok(())
2586}
2587
2588fn cmd_identity(cmd: IdentityCommand) -> Result<()> {
2591 match cmd {
2592 IdentityCommand::Show { json } => cmd_whoami(json, !json, false),
2599 IdentityCommand::List { json } => cmd_session_list(json),
2600 IdentityCommand::Publish {
2601 nick,
2602 relay,
2603 public_url,
2604 hidden,
2605 json,
2606 } => cmd_claim(&nick, relay.as_deref(), public_url.as_deref(), hidden, json),
2607 IdentityCommand::Destroy { name, force, json } => cmd_session_destroy(&name, force, json),
2608 IdentityCommand::Create {
2609 name,
2610 anonymous,
2611 local: _,
2612 json,
2613 } => cmd_identity_create(name.as_deref(), anonymous, json),
2614 IdentityCommand::Persist {
2615 name,
2616 as_name,
2617 json,
2618 } => cmd_identity_persist(&name, as_name.as_deref(), json),
2619 IdentityCommand::Demote { name, json } => cmd_identity_demote(&name, json),
2620 }
2621}
2622
2623fn cmd_identity_create(name: Option<&str>, anonymous: bool, as_json: bool) -> Result<()> {
2628 if anonymous {
2629 let rand_suffix = format!("{:08x}", rand::random::<u32>());
2631 let anon_name = name
2632 .map(crate::session::sanitize_name)
2633 .unwrap_or_else(|| format!("anon-{rand_suffix}"));
2634 let anon_root = std::env::temp_dir().join(format!("wire-anon-{rand_suffix}"));
2635 std::fs::create_dir_all(&anon_root)
2636 .with_context(|| format!("creating anon root {anon_root:?}"))?;
2637 let session_home = anon_root.join("sessions").join(&anon_name);
2639 std::fs::create_dir_all(&session_home)?;
2640 let status = run_wire_with_home(&session_home, &["init", &anon_name, "--offline"])?;
2641 if !status.success() {
2642 bail!("anonymous identity init failed: {status}");
2643 }
2644 let marker = anon_root.join("anon-marker.json");
2647 std::fs::write(
2648 &marker,
2649 serde_json::to_vec_pretty(&serde_json::json!({
2650 "name": anon_name,
2651 "session_home": session_home.to_string_lossy(),
2652 "created_at": time::OffsetDateTime::now_utc()
2653 .format(&time::format_description::well_known::Rfc3339)
2654 .unwrap_or_default(),
2655 "kind": "anonymous",
2656 }))?,
2657 )?;
2658 let card = serde_json::from_slice::<Value>(&std::fs::read(
2659 session_home
2660 .join("config")
2661 .join("wire")
2662 .join("agent-card.json"),
2663 )?)?;
2664 let did = card
2665 .get("did")
2666 .and_then(Value::as_str)
2667 .unwrap_or("")
2668 .to_string();
2669 if as_json {
2670 println!(
2671 "{}",
2672 serde_json::to_string(&json!({
2673 "kind": "anonymous",
2674 "name": anon_name,
2675 "did": did,
2676 "session_home": session_home.to_string_lossy(),
2677 "anon_root": anon_root.to_string_lossy(),
2678 }))?
2679 );
2680 } else {
2681 println!("created anonymous identity `{anon_name}` ({did})");
2682 println!(
2683 " session_home: {} (dies on reboot — /tmp)",
2684 session_home.display()
2685 );
2686 println!();
2687 println!("activate in this shell:");
2688 println!(" export WIRE_HOME={}", session_home.display());
2689 println!();
2690 println!("promote to persistent later with:");
2691 println!(" wire identity persist {anon_name}");
2692 }
2693 return Ok(());
2694 }
2695 let name_arg = name.map(|s| s.to_string());
2697 cmd_session_new(
2698 name_arg.as_deref(),
2699 "https://wireup.net",
2700 false,
2701 "http://127.0.0.1:8771",
2702 false,
2703 None,
2704 false,
2705 None,
2706 true, true, as_json,
2709 )
2710}
2711
2712fn cmd_identity_persist(name: &str, as_name: Option<&str>, as_json: bool) -> Result<()> {
2715 let temp = std::env::temp_dir();
2717 let mut found: Option<(std::path::PathBuf, std::path::PathBuf)> = None;
2718 for entry in std::fs::read_dir(&temp)?.flatten() {
2719 let path = entry.path();
2720 if !path
2721 .file_name()
2722 .and_then(|s| s.to_str())
2723 .map(|s| s.starts_with("wire-anon-"))
2724 .unwrap_or(false)
2725 {
2726 continue;
2727 }
2728 let marker = path.join("anon-marker.json");
2729 if let Ok(bytes) = std::fs::read(&marker)
2730 && let Ok(json) = serde_json::from_slice::<Value>(&bytes)
2731 && json.get("name").and_then(Value::as_str) == Some(name)
2732 {
2733 let session_home = json
2734 .get("session_home")
2735 .and_then(Value::as_str)
2736 .map(std::path::PathBuf::from)
2737 .ok_or_else(|| anyhow!("anon-marker {marker:?} missing session_home"))?;
2738 found = Some((path, session_home));
2739 break;
2740 }
2741 }
2742 let (anon_root, anon_session_home) = found.ok_or_else(|| {
2743 anyhow!(
2744 "no anonymous identity named `{name}` found in /tmp/wire-anon-* — \
2745 run `wire identity list` to see available identities"
2746 )
2747 })?;
2748
2749 let new_name = as_name.unwrap_or(name);
2750 let new_session_home = crate::session::session_dir(new_name)?;
2751 if new_session_home.exists() {
2752 bail!(
2753 "target session `{new_name}` already exists at {new_session_home:?} — \
2754 pick a different name with --as <new-name>"
2755 );
2756 }
2757
2758 if let Some(parent) = new_session_home.parent() {
2760 std::fs::create_dir_all(parent)?;
2761 }
2762 std::fs::rename(&anon_session_home, &new_session_home)
2763 .with_context(|| format!("rename {anon_session_home:?} → {new_session_home:?}"))?;
2764
2765 let _ = std::fs::remove_dir_all(&anon_root);
2767
2768 let cwd = std::env::current_dir().unwrap_or_else(|_| new_session_home.clone());
2771 let cwd_key = cwd.to_string_lossy().into_owned();
2772 let new_name_for_reg = new_name.to_string();
2773 if let Err(e) = crate::session::update_registry(|reg| {
2774 reg.by_cwd.insert(cwd_key, new_name_for_reg);
2775 Ok(())
2776 }) {
2777 eprintln!("wire identity persist: failed to update registry: {e:#}");
2778 }
2779
2780 if as_json {
2781 println!(
2782 "{}",
2783 serde_json::to_string(&json!({
2784 "kind": "persisted",
2785 "from_name": name,
2786 "to_name": new_name,
2787 "session_home": new_session_home.to_string_lossy(),
2788 }))?
2789 );
2790 } else {
2791 println!("persisted anonymous identity `{name}` → local session `{new_name}`");
2792 println!(
2793 " session_home: {} (survives reboot)",
2794 new_session_home.display()
2795 );
2796 println!(" registered cwd: {}", cwd.display());
2797 }
2798 Ok(())
2799}
2800
2801fn cmd_identity_demote(name: &str, as_json: bool) -> Result<()> {
2807 let sessions = crate::session::list_sessions()?;
2808 let session = sessions
2809 .iter()
2810 .find(|s| s.name == name)
2811 .ok_or_else(|| anyhow!("no session named `{name}` (run `wire identity list`)"))?;
2812 let relay_state_path = session
2813 .home_dir
2814 .join("config")
2815 .join("wire")
2816 .join("relay.json");
2817 if !relay_state_path.exists() {
2818 bail!("session `{name}` has no relay state — already demoted?");
2819 }
2820 let mut state: Value = serde_json::from_slice(&std::fs::read(&relay_state_path)?)?;
2821 let self_obj = state.get("self").cloned().unwrap_or(Value::Null);
2822 let had_fed = self_obj
2823 .get("relay_url")
2824 .and_then(Value::as_str)
2825 .map(|u| {
2826 u.starts_with("https://") || (u.starts_with("http://") && !u.contains("127.0.0.1"))
2827 })
2828 .unwrap_or(false);
2829 if !had_fed {
2830 if as_json {
2831 println!(
2832 "{}",
2833 serde_json::to_string(
2834 &json!({"name": name, "status": "no-op", "reason": "no federation slot"})
2835 )?
2836 );
2837 } else {
2838 println!("session `{name}` has no federation slot — nothing to demote");
2839 }
2840 return Ok(());
2841 }
2842 if let Some(self_mut) = state
2845 .as_object_mut()
2846 .and_then(|m| m.get_mut("self"))
2847 .and_then(|s| s.as_object_mut())
2848 {
2849 self_mut.remove("relay_url");
2850 self_mut.remove("slot_id");
2851 self_mut.remove("slot_token");
2852 if let Some(eps) = self_mut.get_mut("endpoints").and_then(|e| e.as_array_mut()) {
2853 eps.retain(|ep| ep.get("scope").and_then(Value::as_str) != Some("federation"));
2854 }
2855 }
2856 std::fs::write(&relay_state_path, serde_json::to_vec_pretty(&state)?)?;
2857
2858 if as_json {
2859 println!(
2860 "{}",
2861 serde_json::to_string(
2862 &json!({"name": name, "status": "demoted", "from": "federation", "to": "local"})
2863 )?
2864 );
2865 } else {
2866 println!("demoted `{name}` from federation → local");
2867 println!(" relay slot binding removed; keypair + agent-card retained");
2868 println!(" re-publish with `wire identity publish <nick>`");
2869 }
2870 Ok(())
2871}
2872
2873fn effective_peer_tier(trust: &Value, relay_state: &Value, handle: &str) -> String {
2874 let raw = crate::trust::get_tier(trust, handle);
2875 if raw != "VERIFIED" {
2876 return raw.to_string();
2877 }
2878 let token = relay_state
2879 .get("peers")
2880 .and_then(|p| p.get(handle))
2881 .and_then(|p| p.get("slot_token"))
2882 .and_then(Value::as_str)
2883 .unwrap_or("");
2884 if token.is_empty() {
2885 "PENDING_ACK".to_string()
2886 } else {
2887 raw.to_string()
2888 }
2889}
2890
2891fn cmd_peers(as_json: bool) -> Result<()> {
2892 let trust = config::read_trust()?;
2893 let agents = trust
2894 .get("agents")
2895 .and_then(Value::as_object)
2896 .cloned()
2897 .unwrap_or_default();
2898 let relay_state = config::read_relay_state().unwrap_or_else(|_| json!({"peers": {}}));
2899
2900 let mut self_did: Option<String> = None;
2901 if let Ok(card) = config::read_agent_card() {
2902 self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
2903 }
2904
2905 let mut peers = Vec::new();
2906 for (handle, agent) in agents.iter() {
2907 let did = agent
2908 .get("did")
2909 .and_then(Value::as_str)
2910 .unwrap_or("")
2911 .to_string();
2912 if Some(did.as_str()) == self_did.as_deref() {
2913 continue; }
2915 let tier = effective_peer_tier(&trust, &relay_state, handle);
2916 let capabilities = agent
2917 .get("card")
2918 .and_then(|c| c.get("capabilities"))
2919 .cloned()
2920 .unwrap_or_else(|| json!([]));
2921 let character = if did.is_empty() {
2926 None
2927 } else {
2928 let card_obj = agent.get("card");
2929 Some(match card_obj {
2930 Some(card) => crate::character::Character::from_card(card),
2931 None => crate::character::Character::from_did(&did),
2932 })
2933 };
2934 peers.push(json!({
2935 "handle": handle,
2936 "did": did,
2937 "tier": tier,
2938 "capabilities": capabilities,
2939 "persona": character,
2940 }));
2941 }
2942
2943 if as_json {
2944 println!("{}", serde_json::to_string(&peers)?);
2945 } else if peers.is_empty() {
2946 println!("no peers pinned (run `wire join <code>` to pair)");
2947 } else {
2948 for p in &peers {
2954 let char_json = &p["persona"];
2955 let (colored_char, plain_len): (String, usize) = match char_json {
2956 serde_json::Value::Null => ("?".to_string(), 1),
2957 v => match serde_json::from_value::<crate::character::Character>(v.clone()) {
2958 Ok(c) => {
2959 let plain = c.short().chars().count() + 1; (c.colored(), plain)
2961 }
2962 Err(_) => ("?".to_string(), 1),
2963 },
2964 };
2965 let pad = 22usize.saturating_sub(plain_len);
2966 println!(
2967 "{}{} {:<20} {:<10} {}",
2968 colored_char,
2969 " ".repeat(pad),
2970 p["handle"].as_str().unwrap_or(""),
2971 p["tier"].as_str().unwrap_or(""),
2972 p["did"].as_str().unwrap_or(""),
2973 );
2974 }
2975 }
2976 Ok(())
2977}
2978
2979fn maybe_warn_peer_attentiveness(peer: &str) {
2989 let state = match config::read_relay_state() {
2990 Ok(s) => s,
2991 Err(_) => return,
2992 };
2993 let p = state.get("peers").and_then(|p| p.get(peer));
2994 let slot_id = match p.and_then(|p| p.get("slot_id")).and_then(Value::as_str) {
2995 Some(s) if !s.is_empty() => s,
2996 _ => return,
2997 };
2998 let slot_token = match p.and_then(|p| p.get("slot_token")).and_then(Value::as_str) {
2999 Some(s) if !s.is_empty() => s,
3000 _ => return,
3001 };
3002 let relay_url = match p.and_then(|p| p.get("relay_url")).and_then(Value::as_str) {
3003 Some(s) if !s.is_empty() => s.to_string(),
3004 _ => match state
3005 .get("self")
3006 .and_then(|s| s.get("relay_url"))
3007 .and_then(Value::as_str)
3008 {
3009 Some(s) if !s.is_empty() => s.to_string(),
3010 _ => return,
3011 },
3012 };
3013 let client = crate::relay_client::RelayClient::new(&relay_url);
3014 let (_count, last_pull) = match client.slot_state(slot_id, slot_token) {
3015 Ok(t) => t,
3016 Err(_) => return,
3017 };
3018 let now = std::time::SystemTime::now()
3019 .duration_since(std::time::UNIX_EPOCH)
3020 .map(|d| d.as_secs())
3021 .unwrap_or(0);
3022 match last_pull {
3023 None => {
3024 eprintln!(
3025 "phyllis: {peer}'s line is silent — relay sees no pulls yet. message will queue, but they may not be listening."
3026 );
3027 }
3028 Some(t) if now.saturating_sub(t) > 300 => {
3029 let mins = now.saturating_sub(t) / 60;
3030 eprintln!(
3031 "phyllis: {peer} hasn't picked up in {mins}m — message will queue, but they may be away."
3032 );
3033 }
3034 _ => {}
3035 }
3036}
3037
3038pub(crate) fn parse_deadline_until(input: &str) -> Result<String> {
3039 let trimmed = input.trim();
3040 if time::OffsetDateTime::parse(trimmed, &time::format_description::well_known::Rfc3339).is_ok()
3041 {
3042 return Ok(trimmed.to_string());
3043 }
3044 let (amount, unit) = trimmed.split_at(trimmed.len().saturating_sub(1));
3045 let n: i64 = amount
3046 .parse()
3047 .with_context(|| format!("deadline must be `30m`, `2h`, `1d`, or RFC3339: {input:?}"))?;
3048 if n <= 0 {
3049 bail!("deadline duration must be positive: {input:?}");
3050 }
3051 let duration = match unit {
3052 "m" => time::Duration::minutes(n),
3053 "h" => time::Duration::hours(n),
3054 "d" => time::Duration::days(n),
3055 _ => bail!("deadline must end in m, h, d, or be RFC3339: {input:?}"),
3056 };
3057 Ok((time::OffsetDateTime::now_utc() + duration)
3058 .format(&time::format_description::well_known::Rfc3339)
3059 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()))
3060}
3061
3062fn cmd_send(
3063 peer: &str,
3064 kind: &str,
3065 body_arg: &str,
3066 deadline: Option<&str>,
3067 no_auto_pair: bool,
3071 as_json: bool,
3072) -> Result<()> {
3073 if !config::is_initialized()? {
3074 bail!("not initialized — run `wire init <handle>` first");
3075 }
3076 let peer_in = crate::agent_card::bare_handle(peer).to_string();
3077 let peer = match resolve_peer_handle(&peer_in) {
3084 Ok(Some(resolved)) if resolved != peer_in => {
3085 eprintln!("wire send: resolved nickname `{peer_in}` → peer `{resolved}`");
3086 resolved
3087 }
3088 Ok(Some(canonical)) => canonical, Ok(None) => peer_in, Err(ResolveError::Ambiguous(candidates)) => bail!(
3091 "nickname `{peer_in}` is ambiguous — matches {} pinned peers: {}. \
3092 Disambiguate by passing the peer handle (one of those listed) instead of the nickname.",
3093 candidates.len(),
3094 candidates.join(", ")
3095 ),
3096 Err(ResolveError::NotFound) => peer_in, };
3098
3099 let peer_is_pinned = config::read_relay_state()
3106 .ok()
3107 .and_then(|s| s.get("peers").and_then(Value::as_object).cloned())
3108 .map(|peers| peers.contains_key(&peer))
3109 .unwrap_or(false);
3110 if !peer_is_pinned && let Some(sister_name) = crate::session::resolve_local_sister(&peer) {
3111 if no_auto_pair {
3112 bail!(
3113 "wire send: `{peer}` resolves to local sister `{sister_name}` but is not pinned, \
3114 and --no-auto-pair was passed. Run `wire dial {peer}` first, \
3115 then re-run send."
3116 );
3117 }
3118 eprintln!(
3119 "wire send: `{peer}` not pinned yet — auto-pairing via local-sister `{sister_name}` first. \
3120 Pass --no-auto-pair to refuse implicit dialing."
3121 );
3122 cmd_add_local_sister(&sister_name, true).map_err(|e| {
3123 anyhow!("wire send: auto-pair to local sister `{sister_name}` failed: {e:#}")
3124 })?;
3125 }
3126
3127 let peer = peer.as_str();
3128 let sk_seed = config::read_private_key()?;
3129 let card = config::read_agent_card()?;
3130 let did = card.get("did").and_then(Value::as_str).unwrap_or("");
3131 let handle = crate::agent_card::display_handle_from_did(did).to_string();
3132 let pk_b64 = card
3133 .get("verify_keys")
3134 .and_then(Value::as_object)
3135 .and_then(|m| m.values().next())
3136 .and_then(|v| v.get("key"))
3137 .and_then(Value::as_str)
3138 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
3139 let pk_bytes = crate::signing::b64decode(pk_b64)?;
3140
3141 let body_value: Value = if body_arg == "-" {
3146 use std::io::Read;
3147 let mut raw = String::new();
3148 std::io::stdin()
3149 .read_to_string(&mut raw)
3150 .with_context(|| "reading body from stdin")?;
3151 serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
3154 } else if let Some(path) = body_arg.strip_prefix('@') {
3155 let raw =
3156 std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
3157 serde_json::from_str(&raw).unwrap_or(Value::String(raw))
3158 } else {
3159 Value::String(body_arg.to_string())
3160 };
3161
3162 let kind_id = parse_kind(kind)?;
3163
3164 let now = time::OffsetDateTime::now_utc()
3165 .format(&time::format_description::well_known::Rfc3339)
3166 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
3167
3168 let mut event = json!({
3169 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
3170 "timestamp": now,
3171 "from": did,
3172 "to": format!("did:wire:{peer}"),
3173 "type": kind,
3174 "kind": kind_id,
3175 "body": body_value,
3176 });
3177 if let Some(deadline) = deadline {
3178 event["time_sensitive_until"] = json!(parse_deadline_until(deadline)?);
3179 }
3180 let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)?;
3181 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
3182
3183 maybe_warn_peer_attentiveness(peer);
3188
3189 let line = serde_json::to_vec(&signed)?;
3194 let outbox = config::append_outbox_record(peer, &line)?;
3195
3196 if as_json {
3197 println!(
3198 "{}",
3199 serde_json::to_string(&json!({
3200 "event_id": event_id,
3201 "status": "queued",
3202 "peer": peer,
3203 "outbox": outbox.to_string_lossy(),
3204 }))?
3205 );
3206 } else {
3207 println!(
3208 "queued event {event_id} → {peer} (outbox: {})",
3209 outbox.display()
3210 );
3211 }
3212 Ok(())
3213}
3214
3215fn parse_kind(s: &str) -> Result<u32> {
3216 if let Ok(n) = s.parse::<u32>() {
3217 return Ok(n);
3218 }
3219 for (id, name) in crate::signing::kinds() {
3220 if *name == s {
3221 return Ok(*id);
3222 }
3223 }
3224 Ok(1)
3226}
3227
3228fn cmd_here(as_json: bool) -> Result<()> {
3234 let initialized = config::is_initialized().unwrap_or(false);
3235
3236 let (self_did, self_handle, self_character) = if initialized {
3238 let card = config::read_agent_card().ok();
3239 let did = card
3240 .as_ref()
3241 .and_then(|c| c.get("did").and_then(Value::as_str))
3242 .unwrap_or("")
3243 .to_string();
3244 let handle = if did.is_empty() {
3245 String::new()
3246 } else {
3247 crate::agent_card::display_handle_from_did(&did).to_string()
3248 };
3249 let character = if did.is_empty() {
3250 None
3251 } else {
3252 Some(crate::character::Character::from_did(&did))
3254 };
3255 (did, handle, character)
3256 } else {
3257 (String::new(), String::new(), None)
3258 };
3259
3260 let cwd = std::env::current_dir()
3261 .map(|p| p.to_string_lossy().into_owned())
3262 .unwrap_or_default();
3263 let wire_home = std::env::var("WIRE_HOME").unwrap_or_default();
3264
3265 let mut sisters: Vec<Value> = Vec::new();
3267 if let Ok(listing) = crate::session::list_local_sessions() {
3268 for group in listing.local.values() {
3269 for s in group {
3270 if s.handle.as_deref() == Some(self_handle.as_str()) {
3271 continue; }
3273 let ch = s.did.as_deref().map(crate::character::Character::from_did);
3274 sisters.push(json!({
3275 "session": s.name,
3276 "handle": s.handle,
3277 "persona": ch,
3278 }));
3279 }
3280 }
3281 }
3282
3283 let mut peers: Vec<Value> = Vec::new();
3285 if initialized
3286 && let Ok(trust) = config::read_trust()
3287 && let Some(agents) = trust.get("agents").and_then(Value::as_object)
3288 {
3289 for (handle, agent) in agents {
3290 if handle == &self_handle {
3291 continue; }
3293 let did = agent.get("did").and_then(Value::as_str).unwrap_or("");
3294 let ch = if did.is_empty() {
3295 None
3296 } else {
3297 Some(crate::character::Character::from_did(did))
3298 };
3299 peers.push(json!({
3300 "handle": handle,
3301 "did": did,
3302 "tier": agent.get("tier").and_then(Value::as_str).unwrap_or("UNKNOWN"),
3303 "persona": ch,
3304 }));
3305 }
3306 }
3307
3308 if as_json {
3309 println!(
3310 "{}",
3311 serde_json::to_string(&json!({
3312 "self": {
3313 "handle": self_handle,
3314 "did": self_did,
3315 "persona": self_character,
3316 "cwd": cwd,
3317 "wire_home": wire_home,
3318 },
3319 "sister_sessions": sisters,
3320 "pinned_peers": peers,
3321 }))?
3322 );
3323 return Ok(());
3324 }
3325
3326 if !initialized {
3328 println!("not initialized — run `wire init <handle>` to bootstrap.");
3329 return Ok(());
3330 }
3331 let glyph = self_character
3332 .as_ref()
3333 .map(crate::character::emoji_with_fallback)
3334 .unwrap_or_else(|| "?".to_string());
3335 let nick = self_character
3336 .as_ref()
3337 .map(|c| c.nickname.clone())
3338 .unwrap_or_default();
3339 println!("you are {glyph} {nick} ({self_handle})");
3340 if !cwd.is_empty() {
3341 println!(" cwd: {cwd}");
3342 }
3343 let render_glyph = |character: &Value| -> String {
3348 let emoji = character
3349 .get("emoji")
3350 .and_then(Value::as_str)
3351 .unwrap_or("?");
3352 let nickname = character
3353 .get("nickname")
3354 .and_then(Value::as_str)
3355 .unwrap_or("?");
3356 if crate::character::terminal_supports_emoji() {
3357 return emoji.to_string();
3358 }
3359 let synth = crate::character::Character {
3362 nickname: nickname.to_string(),
3363 emoji: emoji.to_string(),
3364 palette: crate::character::Palette {
3365 primary_hex: String::new(),
3366 accent_hex: String::new(),
3367 ansi256_primary: 0,
3368 ansi256_accent: 0,
3369 },
3370 };
3371 crate::character::emoji_with_fallback(&synth)
3372 };
3373 if !sisters.is_empty() {
3374 println!();
3375 println!("sister sessions on this machine:");
3376 for s in &sisters {
3377 let session = s["session"].as_str().unwrap_or("?");
3378 let ch_nick = s["persona"]["nickname"].as_str().unwrap_or("?");
3379 let glyph = render_glyph(&s["persona"]);
3380 println!(" {glyph} {ch_nick} ({session})");
3381 }
3382 }
3383 if !peers.is_empty() {
3384 println!();
3385 println!("pinned peers:");
3386 for p in &peers {
3387 let handle = p["handle"].as_str().unwrap_or("?");
3388 let tier = p["tier"].as_str().unwrap_or("");
3389 let ch_nick = p["persona"]["nickname"].as_str().unwrap_or("?");
3390 let glyph = render_glyph(&p["persona"]);
3391 println!(" {glyph} {ch_nick} ({handle}) [{tier}]");
3392 }
3393 }
3394 if sisters.is_empty() && peers.is_empty() {
3395 println!();
3396 println!(
3397 "no neighbors yet — `wire session new` to add a sister, or `wire dial <peer>` to reach out."
3398 );
3399 }
3400 Ok(())
3401}
3402
3403fn cmd_dial(name: &str, message: Option<&str>, as_json: bool) -> Result<()> {
3415 if name.contains('@') {
3416 cmd_add(name, None, false, true)
3422 .map_err(|e| anyhow!("wire dial: federation pair to `{name}` failed: {e:#}"))?;
3423 if let Some(msg) = message {
3424 let bare = name.split('@').next().unwrap_or(name);
3426 cmd_send(bare, "claim", msg, None, false, as_json)?;
3427 }
3428 return Ok(());
3429 }
3430
3431 let resolution = match resolve_name_to_target(name) {
3436 Ok(r) => r,
3437 Err(e) if as_json => {
3438 let pool = known_local_names();
3439 let suggestions = closest_candidates(name, &pool, 3, 3);
3440 println!(
3441 "{}",
3442 serde_json::to_string(&json!({
3443 "name_input": name,
3444 "found": false,
3445 "candidates": suggestions,
3446 "error": format!("{e:#}"),
3447 }))?
3448 );
3449 return Ok(());
3450 }
3451 Err(e) => return Err(e),
3452 };
3453 let mut steps: Vec<Value> = Vec::new();
3454
3455 match &resolution {
3456 DialTarget::PinnedPeer { handle, .. } => {
3457 steps.push(json!({
3458 "step": "resolved",
3459 "kind": "already_pinned",
3460 "handle": handle,
3461 }));
3462 }
3463 DialTarget::LocalSister { session_name, .. } => {
3464 steps.push(json!({
3465 "step": "resolved",
3466 "kind": "local_sister",
3467 "session": session_name,
3468 }));
3469 cmd_add_local_sister(session_name, true).map_err(|e| {
3475 anyhow!("dial: local-sister pair to `{session_name}` failed: {e:#}")
3476 })?;
3477 steps.push(json!({
3478 "step": "paired",
3479 "via": "local_sister",
3480 }));
3481 }
3482 }
3483
3484 let send_handle = match &resolution {
3485 DialTarget::PinnedPeer { handle, .. } => handle.clone(),
3486 DialTarget::LocalSister { handle, .. } => handle.clone(),
3487 };
3488
3489 let send_result = if let Some(msg) = message {
3490 let r = cmd_send(&send_handle, "claim", msg, None, false, true);
3491 match &r {
3492 Ok(()) => steps.push(json!({"step": "sent", "to": send_handle, "kind": "claim"})),
3493 Err(e) => steps.push(json!({"step": "send_failed", "error": format!("{e:#}")})),
3494 }
3495 Some(r)
3496 } else {
3497 None
3498 };
3499
3500 if as_json {
3501 println!(
3502 "{}",
3503 serde_json::to_string(&json!({
3504 "name_input": name,
3505 "resolved_handle": send_handle,
3506 "steps": steps,
3507 }))?
3508 );
3509 } else {
3510 println!("wire dial: resolved `{name}` → handle `{send_handle}`");
3511 for s in &steps {
3512 let step = s.get("step").and_then(Value::as_str).unwrap_or("?");
3513 println!(" - {step}");
3514 }
3515 if message.is_some() {
3516 println!(" (use `wire tail {send_handle}` to read replies)");
3517 }
3518 }
3519 if let Some(Err(e)) = send_result {
3520 return Err(e);
3521 }
3522 Ok(())
3523}
3524
3525fn cmd_whois_local(name: &str, as_json: bool) -> Result<()> {
3531 let resolution = match resolve_name_to_target(name) {
3537 Ok(r) => r,
3538 Err(e) if as_json => {
3539 let pool = known_local_names();
3540 let suggestions = closest_candidates(name, &pool, 3, 3);
3541 println!(
3542 "{}",
3543 serde_json::to_string(&json!({
3544 "name_input": name,
3545 "found": false,
3546 "candidates": suggestions,
3547 "error": format!("{e:#}"),
3548 }))?
3549 );
3550 return Ok(());
3551 }
3552 Err(e) => return Err(e),
3553 };
3554 match resolution {
3555 DialTarget::PinnedPeer {
3556 handle,
3557 did,
3558 nickname,
3559 emoji,
3560 tier,
3561 } => {
3562 if as_json {
3563 println!(
3564 "{}",
3565 serde_json::to_string(&json!({
3566 "kind": "pinned_peer",
3567 "handle": handle,
3568 "did": did,
3569 "nickname": nickname,
3570 "emoji": emoji,
3571 "tier": tier,
3572 }))?
3573 );
3574 } else {
3575 let n = nickname.as_deref().unwrap_or("(no character)");
3576 let e = emoji.as_deref().unwrap_or("?");
3577 println!("{e} {n}");
3578 println!(" handle: {handle}");
3579 println!(" did: {did}");
3580 println!(" tier: {tier}");
3581 println!(" reach: pinned peer (already in trust ring + slot pinned)");
3582 }
3583 }
3584 DialTarget::LocalSister {
3585 session_name,
3586 handle,
3587 did,
3588 nickname,
3589 emoji,
3590 } => {
3591 if as_json {
3592 println!(
3593 "{}",
3594 serde_json::to_string(&json!({
3595 "kind": "local_sister",
3596 "session_name": session_name,
3597 "handle": handle,
3598 "did": did,
3599 "nickname": nickname,
3600 "emoji": emoji,
3601 }))?
3602 );
3603 } else {
3604 let n = nickname.as_deref().unwrap_or("(no character)");
3605 let e = emoji.as_deref().unwrap_or("?");
3606 println!("{e} {n}");
3607 println!(" session: {session_name}");
3608 println!(" handle: {handle}");
3609 println!(
3610 " did: {}",
3611 did.as_deref().unwrap_or("(card unreadable)")
3612 );
3613 println!(" reach: local sister on this machine — `wire dial {n}` pairs us");
3614 }
3615 }
3616 }
3617 Ok(())
3618}
3619
3620enum DialTarget {
3621 PinnedPeer {
3622 handle: String,
3623 did: String,
3624 nickname: Option<String>,
3625 emoji: Option<String>,
3626 tier: String,
3627 },
3628 LocalSister {
3629 session_name: String,
3630 handle: String,
3631 did: Option<String>,
3632 nickname: Option<String>,
3633 emoji: Option<String>,
3634 },
3635}
3636
3637fn resolve_name_to_target(name: &str) -> Result<DialTarget> {
3641 let needle = name.trim();
3642 if needle.is_empty() {
3643 bail!("empty name");
3644 }
3645
3646 if config::is_initialized().unwrap_or(false) {
3649 let trust = config::read_trust().unwrap_or(serde_json::Value::Null);
3650 if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
3651 for (handle_key, agent) in agents {
3652 let did = agent.get("did").and_then(Value::as_str).unwrap_or("");
3653 if did.is_empty() {
3654 continue;
3655 }
3656 let handle = handle_key.clone();
3657 let character = crate::character::Character::from_did(did);
3658 let tier = agent
3659 .get("tier")
3660 .and_then(Value::as_str)
3661 .unwrap_or("UNKNOWN")
3662 .to_string();
3663 let matches = handle.eq_ignore_ascii_case(needle)
3664 || did.eq_ignore_ascii_case(needle)
3665 || character.nickname.eq_ignore_ascii_case(needle);
3666 if matches {
3667 return Ok(DialTarget::PinnedPeer {
3668 handle,
3669 did: did.to_string(),
3670 nickname: Some(character.nickname),
3671 emoji: Some(character.emoji.to_string()),
3672 tier,
3673 });
3674 }
3675 }
3676 }
3677 }
3678
3679 if let Some(session_name) = crate::session::resolve_local_sister(needle) {
3681 let sessions = crate::session::list_sessions().unwrap_or_default();
3682 let s = sessions.iter().find(|s| s.name == session_name);
3683 if let Some(s) = s {
3684 return Ok(DialTarget::LocalSister {
3685 session_name: s.name.clone(),
3686 handle: s.handle.clone().unwrap_or_else(|| s.name.clone()),
3687 did: s.did.clone(),
3688 nickname: s.character.as_ref().map(|c| c.nickname.clone()),
3689 emoji: s.character.as_ref().map(|c| c.emoji.to_string()),
3690 });
3691 }
3692 }
3693
3694 let pool = known_local_names();
3699 let suggestions = closest_candidates(name, &pool, 3, 3);
3700 if suggestions.is_empty() {
3701 bail!(
3702 "no peer matched `{name}`.\n\
3703 Tried: pinned peers (`wire peers`) + local sister sessions \
3704 (`wire session list-local`).\n\
3705 For cross-machine federation: `wire dial <handle>@<relay-domain>`."
3706 );
3707 }
3708 bail!(
3709 "no peer matched `{name}`.\n\
3710 Did you mean: {}?\n\
3711 List all: `wire peers`, `wire session list-local`.",
3712 suggestions
3713 .iter()
3714 .map(|s| format!("`{s}`"))
3715 .collect::<Vec<_>>()
3716 .join(", ")
3717 );
3718}
3719
3720fn cmd_tail(peer: Option<&str>, as_json: bool, limit: usize) -> Result<()> {
3723 let inbox = config::inbox_dir()?;
3724 if !inbox.exists() {
3725 if !as_json {
3726 eprintln!("no inbox yet — daemon hasn't run, or no events received");
3727 }
3728 return Ok(());
3729 }
3730 let trust = config::read_trust()?;
3731 let mut count = 0usize;
3732
3733 let entries: Vec<_> = std::fs::read_dir(&inbox)?
3734 .filter_map(|e| e.ok())
3735 .map(|e| e.path())
3736 .filter(|p| {
3737 p.extension().map(|x| x == "jsonl").unwrap_or(false)
3738 && match peer {
3739 Some(want) => p.file_stem().and_then(|s| s.to_str()) == Some(want),
3740 None => true,
3741 }
3742 })
3743 .collect();
3744
3745 for path in entries {
3746 let body = std::fs::read_to_string(&path)?;
3747 for line in body.lines() {
3748 let event: Value = match serde_json::from_str(line) {
3749 Ok(v) => v,
3750 Err(_) => continue,
3751 };
3752 let verified = verify_message_v31(&event, &trust).is_ok();
3753 if as_json {
3754 let mut event_with_meta = event.clone();
3755 if let Some(obj) = event_with_meta.as_object_mut() {
3756 obj.insert("verified".into(), json!(verified));
3757 }
3758 println!("{}", serde_json::to_string(&event_with_meta)?);
3759 } else {
3760 let ts = event
3761 .get("timestamp")
3762 .and_then(Value::as_str)
3763 .unwrap_or("?");
3764 let from = event.get("from").and_then(Value::as_str).unwrap_or("?");
3765 let kind = event.get("kind").and_then(Value::as_u64).unwrap_or(0);
3766 let kind_name = event.get("type").and_then(Value::as_str).unwrap_or("?");
3767 let summary = event
3768 .get("body")
3769 .map(|b| match b {
3770 Value::String(s) => s.clone(),
3771 _ => b.to_string(),
3772 })
3773 .unwrap_or_default();
3774 let mark = if verified { "✓" } else { "✗" };
3775 let deadline = event
3776 .get("time_sensitive_until")
3777 .and_then(Value::as_str)
3778 .map(|d| format!(" deadline: {d}"))
3779 .unwrap_or_default();
3780 println!("[{ts} {from} kind={kind} {kind_name}{deadline}] {summary} | sig {mark}");
3781 }
3782 count += 1;
3783 if limit > 0 && count >= limit {
3784 return Ok(());
3785 }
3786 }
3787 }
3788 Ok(())
3789}
3790
3791fn monitor_is_noise_kind(kind: &str) -> bool {
3797 matches!(kind, "pair_drop" | "pair_drop_ack" | "heartbeat")
3798}
3799
3800fn resolve_persona(peer_handle: &str) -> Option<crate::character::Character> {
3804 let trust = config::read_trust().ok()?;
3805 let agent = trust.get("agents").and_then(|a| a.get(peer_handle))?;
3806 if let Some(card) = agent.get("card") {
3807 Some(crate::character::Character::from_card(card))
3808 } else {
3809 let did = agent.get("did").and_then(Value::as_str)?;
3810 Some(crate::character::Character::from_did(did))
3811 }
3812}
3813
3814fn persona_label(peer_handle: &str) -> String {
3816 match resolve_persona(peer_handle) {
3817 Some(ch) => format!("{} {}", ch.emoji, ch.nickname),
3818 None => peer_handle.to_string(),
3819 }
3820}
3821
3822fn monitor_render(e: &crate::inbox_watch::InboxEvent, as_json: bool) -> Result<String> {
3830 if as_json {
3831 Ok(serde_json::to_string(e)?)
3832 } else {
3833 let eid_short: String = e.event_id.chars().take(12).collect();
3834 let body = e.body_preview.replace('\n', " ");
3835 let ts: String = e.timestamp.chars().take(19).collect();
3836 Ok(format!("[{ts}] {}/{} ({eid_short}) {body}", e.peer, e.kind))
3837 }
3838}
3839
3840fn cmd_monitor(
3856 peer_filter: Option<&str>,
3857 as_json: bool,
3858 include_handshake: bool,
3859 interval_ms: u64,
3860 replay: usize,
3861) -> Result<()> {
3862 let inbox_dir = config::inbox_dir()?;
3863 if !inbox_dir.exists() && !as_json {
3864 eprintln!("wire monitor: inbox dir {inbox_dir:?} missing — has the daemon ever run?");
3865 }
3866 if replay > 0 && inbox_dir.exists() {
3872 let mut all: Vec<crate::inbox_watch::InboxEvent> = Vec::new();
3873 for entry in std::fs::read_dir(&inbox_dir)?.flatten() {
3874 let path = entry.path();
3875 if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
3876 continue;
3877 }
3878 let peer = match path.file_stem().and_then(|s| s.to_str()) {
3879 Some(s) => s.to_string(),
3880 None => continue,
3881 };
3882 if let Some(filter) = peer_filter
3883 && peer != filter
3884 {
3885 continue;
3886 }
3887 let body = std::fs::read_to_string(&path).unwrap_or_default();
3888 for line in body.lines() {
3889 let line = line.trim();
3890 if line.is_empty() {
3891 continue;
3892 }
3893 let signed: Value = match serde_json::from_str(line) {
3894 Ok(v) => v,
3895 Err(_) => continue,
3896 };
3897 let ev = crate::inbox_watch::InboxEvent::from_signed(
3898 &peer, signed, true,
3899 );
3900 if !include_handshake && monitor_is_noise_kind(&ev.kind) {
3901 continue;
3902 }
3903 all.push(ev);
3904 }
3905 }
3906 all.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
3909 let start = all.len().saturating_sub(replay);
3910 for ev in &all[start..] {
3911 println!("{}", monitor_render(ev, as_json)?);
3912 }
3913 use std::io::Write;
3914 std::io::stdout().flush().ok();
3915 }
3916
3917 let mut w = crate::inbox_watch::InboxWatcher::from_head()?;
3920 let sleep_dur = std::time::Duration::from_millis(interval_ms.max(50));
3921
3922 loop {
3923 let events = w.poll()?;
3924 let mut wrote = false;
3925 for ev in events {
3926 if let Some(filter) = peer_filter
3927 && ev.peer != filter
3928 {
3929 continue;
3930 }
3931 if !include_handshake && monitor_is_noise_kind(&ev.kind) {
3932 continue;
3933 }
3934 println!("{}", monitor_render(&ev, as_json)?);
3935 wrote = true;
3936 }
3937 if wrote {
3938 use std::io::Write;
3939 std::io::stdout().flush().ok();
3940 }
3941 std::thread::sleep(sleep_dur);
3942 }
3943}
3944
3945#[cfg(test)]
3946mod tier_tests {
3947 use super::*;
3948 use serde_json::json;
3949
3950 fn trust_with(handle: &str, tier: &str) -> Value {
3951 json!({
3952 "version": 1,
3953 "agents": {
3954 handle: {
3955 "tier": tier,
3956 "did": format!("did:wire:{handle}"),
3957 "card": {"capabilities": ["wire/v3.1"]}
3958 }
3959 }
3960 })
3961 }
3962
3963 #[test]
3964 fn pending_ack_when_verified_but_no_slot_token() {
3965 let trust = trust_with("willard", "VERIFIED");
3969 let relay_state = json!({
3970 "peers": {
3971 "willard": {
3972 "relay_url": "https://relay",
3973 "slot_id": "abc",
3974 "slot_token": "",
3975 }
3976 }
3977 });
3978 assert_eq!(
3979 effective_peer_tier(&trust, &relay_state, "willard"),
3980 "PENDING_ACK"
3981 );
3982 }
3983
3984 #[test]
3985 fn verified_when_slot_token_present() {
3986 let trust = trust_with("willard", "VERIFIED");
3987 let relay_state = json!({
3988 "peers": {
3989 "willard": {
3990 "relay_url": "https://relay",
3991 "slot_id": "abc",
3992 "slot_token": "tok123",
3993 }
3994 }
3995 });
3996 assert_eq!(
3997 effective_peer_tier(&trust, &relay_state, "willard"),
3998 "VERIFIED"
3999 );
4000 }
4001
4002 #[test]
4003 fn raw_tier_passes_through_for_non_verified() {
4004 let trust = trust_with("willard", "UNTRUSTED");
4007 let relay_state = json!({
4008 "peers": {"willard": {"slot_token": ""}}
4009 });
4010 assert_eq!(
4011 effective_peer_tier(&trust, &relay_state, "willard"),
4012 "UNTRUSTED"
4013 );
4014 }
4015
4016 #[test]
4017 fn pending_ack_when_relay_state_missing_peer() {
4018 let trust = trust_with("willard", "VERIFIED");
4022 let relay_state = json!({"peers": {}});
4023 assert_eq!(
4024 effective_peer_tier(&trust, &relay_state, "willard"),
4025 "PENDING_ACK"
4026 );
4027 }
4028}
4029
4030#[cfg(test)]
4031mod monitor_tests {
4032 use super::*;
4033 use crate::inbox_watch::InboxEvent;
4034 use serde_json::Value;
4035
4036 fn ev(peer: &str, kind: &str, body: &str) -> InboxEvent {
4037 InboxEvent {
4038 peer: peer.to_string(),
4039 event_id: "abcd1234567890ef".to_string(),
4040 kind: kind.to_string(),
4041 body_preview: body.to_string(),
4042 verified: true,
4043 timestamp: "2026-05-15T23:14:07.123456Z".to_string(),
4044 raw: Value::Null,
4045 }
4046 }
4047
4048 #[test]
4049 fn monitor_filter_drops_handshake_kinds_by_default() {
4050 assert!(monitor_is_noise_kind("pair_drop"));
4055 assert!(monitor_is_noise_kind("pair_drop_ack"));
4056 assert!(monitor_is_noise_kind("heartbeat"));
4057
4058 assert!(!monitor_is_noise_kind("claim"));
4060 assert!(!monitor_is_noise_kind("decision"));
4061 assert!(!monitor_is_noise_kind("ack"));
4062 assert!(!monitor_is_noise_kind("request"));
4063 assert!(!monitor_is_noise_kind("note"));
4064 assert!(!monitor_is_noise_kind("future_kind_we_dont_know"));
4068 }
4069
4070 #[test]
4071 fn monitor_render_plain_is_one_short_line() {
4072 let e = ev("willard", "claim", "real v8 train shipped 1350 steps");
4073 let line = monitor_render(&e, false).unwrap();
4074 assert!(!line.contains('\n'), "render must be one line: {line}");
4076 assert!(line.contains("willard"));
4078 assert!(line.contains("claim"));
4079 assert!(line.contains("real v8 train"));
4080 assert!(line.contains("abcd12345678"));
4082 assert!(
4083 !line.contains("abcd1234567890ef"),
4084 "should truncate full id"
4085 );
4086 assert!(line.contains("2026-05-15T23:14:07"));
4088 }
4089
4090 #[test]
4091 fn monitor_render_strips_newlines_from_body() {
4092 let e = ev("spark", "claim", "line one\nline two\nline three");
4097 let line = monitor_render(&e, false).unwrap();
4098 assert!(!line.contains('\n'), "newlines must be stripped: {line}");
4099 assert!(line.contains("line one line two line three"));
4100 }
4101
4102 #[test]
4103 fn monitor_render_json_is_valid_jsonl() {
4104 let e = ev("spark", "claim", "hi");
4105 let line = monitor_render(&e, true).unwrap();
4106 assert!(!line.contains('\n'));
4107 let parsed: Value = serde_json::from_str(&line).expect("valid JSONL");
4108 assert_eq!(parsed["peer"], "spark");
4109 assert_eq!(parsed["kind"], "claim");
4110 assert_eq!(parsed["body_preview"], "hi");
4111 }
4112
4113 #[test]
4114 fn monitor_does_not_drop_on_verified_null() {
4115 let mut e = ev("spark", "claim", "from disk with verified=null");
4126 e.verified = false; let line = monitor_render(&e, false).unwrap();
4128 assert!(line.contains("from disk with verified=null"));
4129 assert!(!monitor_is_noise_kind("claim"));
4131 }
4132}
4133
4134fn cmd_verify(path: &str, as_json: bool) -> Result<()> {
4137 let body = if path == "-" {
4138 let mut buf = String::new();
4139 use std::io::Read;
4140 std::io::stdin().read_to_string(&mut buf)?;
4141 buf
4142 } else {
4143 std::fs::read_to_string(path).with_context(|| format!("reading {path}"))?
4144 };
4145 let event: Value = serde_json::from_str(&body)?;
4146 let trust = config::read_trust()?;
4147 match verify_message_v31(&event, &trust) {
4148 Ok(()) => {
4149 if as_json {
4150 println!("{}", serde_json::to_string(&json!({"verified": true}))?);
4151 } else {
4152 println!("verified ✓");
4153 }
4154 Ok(())
4155 }
4156 Err(e) => {
4157 let reason = e.to_string();
4158 if as_json {
4159 println!(
4160 "{}",
4161 serde_json::to_string(&json!({"verified": false, "reason": reason}))?
4162 );
4163 } else {
4164 eprintln!("FAILED: {reason}");
4165 }
4166 std::process::exit(1);
4167 }
4168 }
4169}
4170
4171fn cmd_mcp() -> Result<()> {
4174 crate::mcp::run()
4175}
4176
4177fn cmd_relay_server(bind: &str, local_only: bool, uds: Option<&std::path::Path>) -> Result<()> {
4178 if let Some(socket_path) = uds {
4183 let base = if let Ok(home) = std::env::var("WIRE_HOME") {
4184 std::path::PathBuf::from(home)
4185 .join("state")
4186 .join("wire-relay")
4187 .join("uds")
4188 } else {
4189 dirs::state_dir()
4190 .or_else(dirs::data_local_dir)
4191 .ok_or_else(|| anyhow::anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?
4192 .join("wire-relay")
4193 .join("uds")
4194 };
4195 let runtime = tokio::runtime::Builder::new_multi_thread()
4196 .enable_all()
4197 .build()?;
4198 return runtime.block_on(crate::relay_server::serve_uds(
4199 socket_path.to_path_buf(),
4200 base,
4201 ));
4202 }
4203 if local_only {
4207 validate_loopback_bind(bind)?;
4208 }
4209 let base = if let Ok(home) = std::env::var("WIRE_HOME") {
4215 std::path::PathBuf::from(home)
4216 .join("state")
4217 .join("wire-relay")
4218 } else {
4219 dirs::state_dir()
4220 .or_else(dirs::data_local_dir)
4221 .ok_or_else(|| anyhow::anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?
4222 .join("wire-relay")
4223 };
4224 let state_dir = if local_only { base.join("local") } else { base };
4225 let runtime = tokio::runtime::Builder::new_multi_thread()
4226 .enable_all()
4227 .build()?;
4228 runtime.block_on(crate::relay_server::serve_with_mode(
4229 bind,
4230 state_dir,
4231 crate::relay_server::ServerMode { local_only },
4232 ))
4233}
4234
4235fn validate_loopback_bind(bind: &str) -> Result<()> {
4253 let host = if let Some(stripped) = bind.strip_prefix('[') {
4255 let close = stripped
4256 .find(']')
4257 .ok_or_else(|| anyhow::anyhow!("malformed IPv6 bind {bind:?}"))?;
4258 stripped[..close].to_string()
4259 } else {
4260 bind.rsplit_once(':')
4261 .map(|(h, _)| h.to_string())
4262 .unwrap_or_else(|| bind.to_string())
4263 };
4264 use std::net::{IpAddr, ToSocketAddrs};
4265 let probe = format!("{host}:0");
4266 let resolved: Vec<_> = probe
4267 .to_socket_addrs()
4268 .with_context(|| format!("resolving bind host {host:?}"))?
4269 .collect();
4270 if resolved.is_empty() {
4271 bail!("--local-only: bind host {host:?} resolved to no addresses");
4272 }
4273 for addr in &resolved {
4274 let ip = addr.ip();
4275 let is_acceptable = match ip {
4276 IpAddr::V4(v4) => {
4277 v4.is_loopback() || v4.is_private() || {
4278 let octets = v4.octets();
4280 octets[0] == 100 && (64..=127).contains(&octets[1])
4281 }
4282 }
4283 IpAddr::V6(v6) => v6.is_loopback(), };
4285 if !is_acceptable {
4286 bail!(
4287 "--local-only refuses non-private bind: {host:?} resolves to {} \
4288 which is not loopback (127/8, ::1), RFC 1918 private \
4289 (10/8, 172.16/12, 192.168/16), or RFC 6598 CGNAT/Tailscale \
4290 (100.64.0.0/10). Remove --local-only to bind publicly.",
4291 ip
4292 );
4293 }
4294 }
4295 Ok(())
4296}
4297
4298fn parse_scope(s: &str) -> Result<crate::endpoints::EndpointScope> {
4301 use crate::endpoints::EndpointScope;
4302 match s.to_lowercase().as_str() {
4303 "federation" | "fed" => Ok(EndpointScope::Federation),
4304 "local" => Ok(EndpointScope::Local),
4305 "lan" => Ok(EndpointScope::Lan),
4306 "uds" => Ok(EndpointScope::Uds),
4307 other => bail!("unknown --scope `{other}` (expected federation|local|lan|uds)"),
4308 }
4309}
4310
4311fn cmd_bind_relay(
4317 url: &str,
4318 scope: Option<&str>,
4319 replace: bool,
4320 migrate_pinned: bool,
4321 as_json: bool,
4322) -> Result<()> {
4323 use crate::endpoints::{Endpoint, self_endpoints};
4324
4325 if !config::is_initialized()? {
4326 bail!("not initialized — run `wire init <handle>` first");
4327 }
4328 let card = config::read_agent_card()?;
4329 let did = card.get("did").and_then(Value::as_str).unwrap_or("");
4330 let handle = crate::agent_card::display_handle_from_did(did).to_string();
4331
4332 let normalized = url.trim_end_matches('/');
4333 let new_scope = match scope {
4334 Some(s) => parse_scope(s)?,
4335 None => crate::endpoints::infer_scope_from_url(normalized),
4336 };
4337
4338 let existing = config::read_relay_state().unwrap_or_else(|_| json!({}));
4339 let pinned: Vec<String> = existing
4340 .get("peers")
4341 .and_then(|p| p.as_object())
4342 .map(|o| o.keys().cloned().collect())
4343 .unwrap_or_default();
4344
4345 let existing_eps = self_endpoints(&existing);
4346 let is_rebind_same = existing_eps.iter().any(|e| e.relay_url == normalized);
4347
4348 let destructive = replace || is_rebind_same;
4355 if destructive && !pinned.is_empty() && !migrate_pinned {
4356 let list = pinned.join(", ");
4357 let why = if replace {
4358 "`--replace` drops your other slot(s)"
4359 } else {
4360 "re-binding the same relay rotates its slot"
4361 };
4362 bail!(
4363 "bind-relay would black-hole {n} pinned peer(s): {list}. {why}; they are \
4364 pinned to your CURRENT slot and would keep pushing to a slot you no longer \
4365 read.\n\n\
4366 SAFE PATHS:\n\
4367 • Default (omit `--replace`) ADDITIVELY binds a NEW relay, keeping existing \
4368 slots — no black-hole.\n\
4369 • `wire rotate-slot` — same-relay rotation that emits wire_close to peers.\n\
4370 • `wire bind-relay {url} --migrate-pinned` — proceed anyway; re-pair each \
4371 peer out-of-band.\n\n\
4372 Issue #7 (silent black-hole on relay change) caught this.",
4373 n = pinned.len(),
4374 );
4375 }
4376
4377 let client = crate::relay_client::RelayClient::new(normalized);
4378 client.check_healthz()?;
4379 let alloc = client.allocate_slot(Some(&handle))?;
4380
4381 if destructive && !pinned.is_empty() {
4382 eprintln!(
4383 "wire bind-relay: {mode} with {n} pinned peer(s) — they will black-hole \
4384 until they re-pin: {peers}",
4385 mode = if replace { "replacing" } else { "rotating" },
4386 n = pinned.len(),
4387 peers = pinned.join(", "),
4388 );
4389 }
4390
4391 let mut state = existing;
4395 if replace {
4396 state["self"] = Value::Null;
4397 }
4398 crate::endpoints::upsert_self_endpoint(
4399 &mut state,
4400 Endpoint {
4401 relay_url: normalized.to_string(),
4402 slot_id: alloc.slot_id.clone(),
4403 slot_token: alloc.slot_token.clone(),
4404 scope: new_scope,
4405 },
4406 );
4407 config::write_relay_state(&state)?;
4408 let eps = self_endpoints(&state);
4409
4410 let scope_str = format!("{new_scope:?}").to_lowercase();
4411 if as_json {
4412 println!(
4413 "{}",
4414 serde_json::to_string(&json!({
4415 "relay_url": normalized,
4416 "slot_id": alloc.slot_id,
4417 "scope": scope_str,
4418 "endpoints": eps.len(),
4419 "additive": !replace,
4420 "slot_token_present": true,
4421 }))?
4422 );
4423 } else {
4424 println!(
4425 "bound {scope_str} slot on {normalized} (slot {})",
4426 alloc.slot_id
4427 );
4428 println!(
4429 "self now has {n} endpoint(s): {list}",
4430 n = eps.len(),
4431 list = eps
4432 .iter()
4433 .map(|e| format!("{}({:?})", e.relay_url, e.scope))
4434 .collect::<Vec<_>>()
4435 .join(", "),
4436 );
4437 }
4438 Ok(())
4439}
4440
4441fn cmd_add_peer_slot(
4444 handle: &str,
4445 url: &str,
4446 slot_id: &str,
4447 slot_token: &str,
4448 as_json: bool,
4449) -> Result<()> {
4450 let mut state = config::read_relay_state()?;
4451 let peers = state["peers"]
4452 .as_object_mut()
4453 .ok_or_else(|| anyhow!("relay state missing 'peers' object"))?;
4454 peers.insert(
4455 handle.to_string(),
4456 json!({
4457 "relay_url": url,
4458 "slot_id": slot_id,
4459 "slot_token": slot_token,
4460 }),
4461 );
4462 config::write_relay_state(&state)?;
4463 if as_json {
4464 println!(
4465 "{}",
4466 serde_json::to_string(&json!({
4467 "handle": handle,
4468 "relay_url": url,
4469 "slot_id": slot_id,
4470 "added": true,
4471 }))?
4472 );
4473 } else {
4474 println!("pinned peer slot for {handle} at {url} ({slot_id})");
4475 }
4476 Ok(())
4477}
4478
4479fn cmd_push(peer_filter: Option<&str>, as_json: bool) -> Result<()> {
4482 let state = config::read_relay_state()?;
4483 let peers = state["peers"].as_object().cloned().unwrap_or_default();
4484 if peers.is_empty() {
4485 bail!(
4486 "no peer slots pinned — run `wire add-peer-slot <handle> <url> <slot_id> <token>` first"
4487 );
4488 }
4489 let outbox_dir = config::outbox_dir()?;
4490 if outbox_dir.exists() {
4495 let pinned: std::collections::HashSet<String> = peers.keys().cloned().collect();
4496 for entry in std::fs::read_dir(&outbox_dir)?.flatten() {
4497 let path = entry.path();
4498 if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
4499 continue;
4500 }
4501 let stem = match path.file_stem().and_then(|s| s.to_str()) {
4502 Some(s) => s.to_string(),
4503 None => continue,
4504 };
4505 if pinned.contains(&stem) {
4506 continue;
4507 }
4508 let bare = crate::agent_card::bare_handle(&stem);
4511 if pinned.contains(bare) {
4512 eprintln!(
4513 "wire push: WARN stale outbox file `{}.jsonl` not enumerated (pinned peer is `{bare}`). \
4514 Merge with: `cat {} >> {}` then delete the FQDN file.",
4515 stem,
4516 path.display(),
4517 outbox_dir.join(format!("{bare}.jsonl")).display(),
4518 );
4519 }
4520 }
4521 }
4522 if !outbox_dir.exists() {
4523 if as_json {
4524 println!(
4525 "{}",
4526 serde_json::to_string(&json!({"pushed": [], "skipped": []}))?
4527 );
4528 } else {
4529 println!("phyllis: nothing to dial out — write a message first with `wire send`");
4530 }
4531 return Ok(());
4532 }
4533
4534 let mut pushed = Vec::new();
4535 let mut skipped = Vec::new();
4536
4537 for (peer_handle, _) in peers.iter() {
4543 if let Some(want) = peer_filter
4544 && peer_handle != want
4545 {
4546 continue;
4547 }
4548 let outbox = outbox_dir.join(format!("{peer_handle}.jsonl"));
4549 if !outbox.exists() {
4550 continue;
4551 }
4552 let ordered_endpoints =
4553 crate::endpoints::peer_endpoints_in_priority_order(&state, peer_handle);
4554 if ordered_endpoints.is_empty() {
4555 for line in std::fs::read_to_string(&outbox).unwrap_or_default().lines() {
4559 let event: Value = match serde_json::from_str(line) {
4560 Ok(v) => v,
4561 Err(_) => continue,
4562 };
4563 let event_id = event
4564 .get("event_id")
4565 .and_then(Value::as_str)
4566 .unwrap_or("")
4567 .to_string();
4568 skipped.push(json!({
4569 "peer": peer_handle,
4570 "event_id": event_id,
4571 "reason": "no reachable endpoint pinned for peer",
4572 }));
4573 }
4574 continue;
4575 }
4576 let body = std::fs::read_to_string(&outbox)?;
4577 for line in body.lines() {
4578 let event: Value = match serde_json::from_str(line) {
4579 Ok(v) => v,
4580 Err(_) => continue,
4581 };
4582 let event_id = event
4583 .get("event_id")
4584 .and_then(Value::as_str)
4585 .unwrap_or("")
4586 .to_string();
4587
4588 let mut delivered = false;
4589 let mut last_err_reason: Option<String> = None;
4590 for endpoint in &ordered_endpoints {
4591 let client = crate::relay_client::RelayClient::new(&endpoint.relay_url);
4592 match client.post_event(&endpoint.slot_id, &endpoint.slot_token, &event) {
4593 Ok(resp) => {
4594 if resp.status == "duplicate" {
4595 skipped.push(json!({
4596 "peer": peer_handle,
4597 "event_id": event_id,
4598 "reason": "duplicate",
4599 "endpoint": endpoint.relay_url,
4600 "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
4601 }));
4602 } else {
4603 pushed.push(json!({
4604 "peer": peer_handle,
4605 "event_id": event_id,
4606 "endpoint": endpoint.relay_url,
4607 "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
4608 }));
4609 }
4610 delivered = true;
4611 break;
4612 }
4613 Err(e) => {
4614 last_err_reason = Some(crate::relay_client::format_transport_error(&e));
4619 }
4620 }
4621 }
4622 if !delivered {
4623 skipped.push(json!({
4624 "peer": peer_handle,
4625 "event_id": event_id,
4626 "reason": last_err_reason.unwrap_or_else(|| "all endpoints failed".to_string()),
4627 }));
4628 }
4629 }
4630 }
4631
4632 if as_json {
4633 println!(
4634 "{}",
4635 serde_json::to_string(&json!({"pushed": pushed, "skipped": skipped}))?
4636 );
4637 } else {
4638 println!(
4639 "pushed {} event(s); skipped {} ({})",
4640 pushed.len(),
4641 skipped.len(),
4642 if skipped.is_empty() {
4643 "none"
4644 } else {
4645 "see --json for detail"
4646 }
4647 );
4648 }
4649 Ok(())
4650}
4651
4652fn cmd_pull(as_json: bool) -> Result<()> {
4655 let state = config::read_relay_state()?;
4656 let self_state = state.get("self").cloned().unwrap_or(Value::Null);
4657 if self_state.is_null() {
4658 bail!("self slot not bound — run `wire bind-relay <url>` first");
4659 }
4660
4661 let endpoints = crate::endpoints::self_endpoints(&state);
4670 if endpoints.is_empty() {
4671 bail!("self.relay_url / slot_id / slot_token missing in relay_state.json");
4672 }
4673
4674 let inbox_dir = config::inbox_dir()?;
4675 config::ensure_dirs()?;
4676
4677 let mut total_seen = 0usize;
4678 let mut all_written: Vec<Value> = Vec::new();
4679 let mut all_rejected: Vec<Value> = Vec::new();
4680 let mut all_blocked = false;
4681 let mut all_advance_cursor_to: Option<String> = None;
4682
4683 for endpoint in &endpoints {
4684 let cursor_key = endpoint_cursor_key(endpoint.scope);
4685 let last_event_id = self_state
4686 .get(&cursor_key)
4687 .and_then(Value::as_str)
4688 .map(str::to_string);
4689 let client = crate::relay_client::RelayClient::new(&endpoint.relay_url);
4690 let events = match client.list_events(
4691 &endpoint.slot_id,
4692 &endpoint.slot_token,
4693 last_event_id.as_deref(),
4694 Some(1000),
4695 ) {
4696 Ok(ev) => ev,
4697 Err(e) => {
4698 eprintln!(
4702 "wire pull: endpoint {} ({:?}) errored: {}; continuing",
4703 endpoint.relay_url,
4704 endpoint.scope,
4705 crate::relay_client::format_transport_error(&e),
4706 );
4707 continue;
4708 }
4709 };
4710 total_seen += events.len();
4711 let result = crate::pull::process_events(&events, last_event_id.clone(), &inbox_dir)?;
4712 all_written.extend(result.written.iter().cloned());
4713 all_rejected.extend(result.rejected.iter().cloned());
4714 if result.blocked {
4715 all_blocked = true;
4716 }
4717 if let Some(eid) = result.advance_cursor_to.clone() {
4720 if endpoint.scope == crate::endpoints::EndpointScope::Federation {
4721 all_advance_cursor_to = Some(eid.clone());
4722 }
4723 let key = cursor_key.clone();
4724 config::update_relay_state(|state| {
4725 if let Some(self_obj) = state.get_mut("self").and_then(Value::as_object_mut) {
4726 self_obj.insert(key, Value::String(eid));
4727 }
4728 Ok(())
4729 })?;
4730 }
4731 }
4732
4733 let result = crate::pull::PullResult {
4738 written: all_written,
4739 rejected: all_rejected,
4740 blocked: all_blocked,
4741 advance_cursor_to: all_advance_cursor_to,
4742 };
4743 let events_len = total_seen;
4744
4745 if as_json {
4749 println!(
4750 "{}",
4751 serde_json::to_string(&json!({
4752 "written": result.written,
4753 "rejected": result.rejected,
4754 "total_seen": events_len,
4755 "cursor_blocked": result.blocked,
4756 "cursor_advanced_to": result.advance_cursor_to,
4757 }))?
4758 );
4759 } else {
4760 let blocking = result
4761 .rejected
4762 .iter()
4763 .filter(|r| r.get("blocks_cursor").and_then(Value::as_bool) == Some(true))
4764 .count();
4765 if blocking > 0 {
4766 println!(
4767 "pulled {} event(s); wrote {}; rejected {} ({} BLOCKING cursor — see `wire pull --json`)",
4768 events_len,
4769 result.written.len(),
4770 result.rejected.len(),
4771 blocking,
4772 );
4773 } else {
4774 println!(
4775 "pulled {} event(s); wrote {}; rejected {}",
4776 events_len,
4777 result.written.len(),
4778 result.rejected.len(),
4779 );
4780 }
4781 }
4782 Ok(())
4783}
4784
4785fn endpoint_cursor_key(scope: crate::endpoints::EndpointScope) -> String {
4790 match scope {
4791 crate::endpoints::EndpointScope::Federation => "last_pulled_event_id".to_string(),
4792 crate::endpoints::EndpointScope::Local => "last_pulled_event_id_local".to_string(),
4793 crate::endpoints::EndpointScope::Lan => "last_pulled_event_id_lan".to_string(),
4794 crate::endpoints::EndpointScope::Uds => "last_pulled_event_id_uds".to_string(),
4795 }
4796}
4797
4798fn cmd_rotate_slot(no_announce: bool, as_json: bool) -> Result<()> {
4801 if !config::is_initialized()? {
4802 bail!("not initialized — run `wire init <handle>` first");
4803 }
4804 let mut state = config::read_relay_state()?;
4805 let self_state = state.get("self").cloned().unwrap_or(Value::Null);
4806 if self_state.is_null() {
4807 bail!("self slot not bound — run `wire bind-relay <url>` first (nothing to rotate)");
4808 }
4809 let primary = crate::endpoints::self_primary_endpoint(&state)
4813 .ok_or_else(|| anyhow!("self has no resolvable inbound endpoint to rotate"))?;
4814 let url = primary.relay_url.clone();
4815 let old_slot_id = primary.slot_id.clone();
4816 let old_slot_token = primary.slot_token.clone();
4817
4818 let card = config::read_agent_card()?;
4820 let did = card
4821 .get("did")
4822 .and_then(Value::as_str)
4823 .unwrap_or("")
4824 .to_string();
4825 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
4826 let pk_b64 = card
4827 .get("verify_keys")
4828 .and_then(Value::as_object)
4829 .and_then(|m| m.values().next())
4830 .and_then(|v| v.get("key"))
4831 .and_then(Value::as_str)
4832 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?
4833 .to_string();
4834 let pk_bytes = crate::signing::b64decode(&pk_b64)?;
4835 let sk_seed = config::read_private_key()?;
4836
4837 let normalized = url.trim_end_matches('/').to_string();
4839 let client = crate::relay_client::RelayClient::new(&normalized);
4840 client
4841 .check_healthz()
4842 .context("aborting rotation; old slot still valid")?;
4843 let alloc = client.allocate_slot(Some(&handle))?;
4844 let new_slot_id = alloc.slot_id.clone();
4845 let new_slot_token = alloc.slot_token.clone();
4846
4847 let mut announced: Vec<String> = Vec::new();
4854 if !no_announce {
4855 let now = time::OffsetDateTime::now_utc()
4856 .format(&time::format_description::well_known::Rfc3339)
4857 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
4858 let body = json!({
4859 "reason": "operator-initiated slot rotation",
4860 "new_relay_url": url,
4861 "new_slot_id": new_slot_id,
4862 });
4866 let peers = state["peers"].as_object().cloned().unwrap_or_default();
4867 for (peer_handle, _peer_info) in peers.iter() {
4868 let event = json!({
4869 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
4870 "timestamp": now.clone(),
4871 "from": did,
4872 "to": format!("did:wire:{peer_handle}"),
4873 "type": "wire_close",
4874 "kind": 1201,
4875 "body": body.clone(),
4876 });
4877 let signed = match sign_message_v31(&event, &sk_seed, &pk_bytes, &handle) {
4878 Ok(s) => s,
4879 Err(e) => {
4880 eprintln!("warn: could not sign wire_close for {peer_handle}: {e}");
4881 continue;
4882 }
4883 };
4884 let peer_info = match state["peers"].get(peer_handle) {
4889 Some(p) => p.clone(),
4890 None => continue,
4891 };
4892 let peer_url = peer_info["relay_url"].as_str().unwrap_or(&url);
4893 let peer_slot_id = peer_info["slot_id"].as_str().unwrap_or("");
4894 let peer_slot_token = peer_info["slot_token"].as_str().unwrap_or("");
4895 if peer_slot_id.is_empty() || peer_slot_token.is_empty() {
4896 continue;
4897 }
4898 let peer_client = if peer_url == url {
4899 client.clone()
4900 } else {
4901 crate::relay_client::RelayClient::new(peer_url)
4902 };
4903 match peer_client.post_event(peer_slot_id, peer_slot_token, &signed) {
4904 Ok(_) => announced.push(peer_handle.clone()),
4905 Err(e) => eprintln!("warn: announce to {peer_handle} failed: {e}"),
4906 }
4907 }
4908 }
4909
4910 state["self"] = json!({
4912 "relay_url": url,
4913 "slot_id": new_slot_id,
4914 "slot_token": new_slot_token,
4915 });
4916 config::write_relay_state(&state)?;
4917
4918 if as_json {
4919 println!(
4920 "{}",
4921 serde_json::to_string(&json!({
4922 "rotated": true,
4923 "old_slot_id": old_slot_id,
4924 "new_slot_id": new_slot_id,
4925 "relay_url": url,
4926 "announced_to": announced,
4927 }))?
4928 );
4929 } else {
4930 println!("rotated slot on {url}");
4931 println!(
4932 " old slot_id: {old_slot_id} (orphaned — abusive bearer-holders lose their leverage)"
4933 );
4934 println!(" new slot_id: {new_slot_id}");
4935 if !announced.is_empty() {
4936 println!(
4937 " announced wire_close (kind=1201) to: {}",
4938 announced.join(", ")
4939 );
4940 }
4941 println!();
4942 println!("next steps:");
4943 println!(" - peers see the wire_close event in their next `wire pull`");
4944 println!(
4945 " - paired peers must re-issue: tell them to run `wire add-peer-slot {handle} {url} {new_slot_id} <new-token>`"
4946 );
4947 println!(" (or full re-pair via `wire pair-host`/`wire join`)");
4948 println!(" - until they do, you'll receive but they won't be able to reach you");
4949 let _ = old_slot_token;
4951 }
4952 Ok(())
4953}
4954
4955fn cmd_forget_peer(handle: &str, purge: bool, as_json: bool) -> Result<()> {
4958 let mut trust = config::read_trust()?;
4959 let mut removed_from_trust = false;
4960 if let Some(agents) = trust.get_mut("agents").and_then(Value::as_object_mut)
4961 && agents.remove(handle).is_some()
4962 {
4963 removed_from_trust = true;
4964 }
4965 config::write_trust(&trust)?;
4966
4967 let mut state = config::read_relay_state()?;
4968 let mut removed_from_relay = false;
4969 if let Some(peers) = state.get_mut("peers").and_then(Value::as_object_mut)
4970 && peers.remove(handle).is_some()
4971 {
4972 removed_from_relay = true;
4973 }
4974 config::write_relay_state(&state)?;
4975
4976 let mut purged: Vec<String> = Vec::new();
4977 if purge {
4978 for dir in [config::inbox_dir()?, config::outbox_dir()?] {
4979 let path = dir.join(format!("{handle}.jsonl"));
4980 if path.exists() {
4981 std::fs::remove_file(&path).with_context(|| format!("removing {path:?}"))?;
4982 purged.push(path.to_string_lossy().into());
4983 }
4984 }
4985 }
4986
4987 if !removed_from_trust && !removed_from_relay {
4988 if as_json {
4989 println!(
4990 "{}",
4991 serde_json::to_string(&json!({
4992 "removed": false,
4993 "reason": format!("peer {handle:?} not pinned"),
4994 }))?
4995 );
4996 } else {
4997 eprintln!("peer {handle:?} not found in trust or relay state — nothing to forget");
4998 }
4999 return Ok(());
5000 }
5001
5002 if as_json {
5003 println!(
5004 "{}",
5005 serde_json::to_string(&json!({
5006 "handle": handle,
5007 "removed_from_trust": removed_from_trust,
5008 "removed_from_relay_state": removed_from_relay,
5009 "purged_files": purged,
5010 }))?
5011 );
5012 } else {
5013 println!("forgot peer {handle:?}");
5014 if removed_from_trust {
5015 println!(" - removed from trust.json");
5016 }
5017 if removed_from_relay {
5018 println!(" - removed from relay.json");
5019 }
5020 if !purged.is_empty() {
5021 for p in &purged {
5022 println!(" - deleted {p}");
5023 }
5024 } else if !purge {
5025 println!(" (inbox/outbox files preserved; pass --purge to delete them)");
5026 }
5027 }
5028 Ok(())
5029}
5030
5031fn cmd_daemon(interval_secs: u64, once: bool, as_json: bool) -> Result<()> {
5034 if !config::is_initialized()? {
5035 bail!("not initialized — run `wire init <handle>` first");
5036 }
5037 let interval = std::time::Duration::from_secs(interval_secs.max(1));
5038
5039 if !as_json {
5040 if once {
5041 eprintln!("wire daemon: single sync cycle, then exit");
5042 } else {
5043 eprintln!("wire daemon: syncing every {interval_secs}s. SIGINT to stop.");
5044 }
5045 }
5046
5047 if let Err(e) = crate::pending_pair::cleanup_on_startup() {
5051 eprintln!("daemon: pending-pair cleanup_on_startup error: {e:#}");
5052 }
5053
5054 let (wake_tx, wake_rx) = std::sync::mpsc::channel::<()>();
5060 if !once {
5061 crate::daemon_stream::spawn_stream_subscriber(wake_tx);
5062 }
5063
5064 loop {
5065 let pushed = run_sync_push().unwrap_or_else(|e| {
5066 eprintln!("daemon: push error: {e:#}");
5067 json!({"pushed": [], "skipped": [{"error": e.to_string()}]})
5068 });
5069 let pulled = run_sync_pull().unwrap_or_else(|e| {
5070 eprintln!("daemon: pull error: {e:#}");
5071 json!({"written": [], "rejected": [], "total_seen": 0, "error": e.to_string()})
5072 });
5073 let pairs = crate::pending_pair::tick().unwrap_or_else(|e| {
5074 eprintln!("daemon: pending-pair tick error: {e:#}");
5075 json!({"transitions": []})
5076 });
5077
5078 if as_json {
5079 println!(
5080 "{}",
5081 serde_json::to_string(&json!({
5082 "ts": time::OffsetDateTime::now_utc()
5083 .format(&time::format_description::well_known::Rfc3339)
5084 .unwrap_or_default(),
5085 "push": pushed,
5086 "pull": pulled,
5087 "pairs": pairs,
5088 }))?
5089 );
5090 } else {
5091 let pushed_n = pushed["pushed"].as_array().map(|a| a.len()).unwrap_or(0);
5092 let written_n = pulled["written"].as_array().map(|a| a.len()).unwrap_or(0);
5093 let rejected_n = pulled["rejected"].as_array().map(|a| a.len()).unwrap_or(0);
5094 let pair_transitions = pairs["transitions"]
5095 .as_array()
5096 .map(|a| a.len())
5097 .unwrap_or(0);
5098 if pushed_n > 0 || written_n > 0 || rejected_n > 0 || pair_transitions > 0 {
5099 eprintln!(
5100 "daemon: pushed={pushed_n} pulled={written_n} rejected={rejected_n} pair-transitions={pair_transitions}"
5101 );
5102 }
5103 if let Some(arr) = pairs["transitions"].as_array() {
5105 for t in arr {
5106 eprintln!(
5107 " pair {} : {} → {}",
5108 t.get("code").and_then(Value::as_str).unwrap_or("?"),
5109 t.get("from").and_then(Value::as_str).unwrap_or("?"),
5110 t.get("to").and_then(Value::as_str).unwrap_or("?")
5111 );
5112 if let Some(sas) = t.get("sas").and_then(Value::as_str)
5113 && t.get("to").and_then(Value::as_str) == Some("sas_ready")
5114 {
5115 eprintln!(" SAS digits: {}-{}", &sas[..3], &sas[3..]);
5116 eprintln!(
5117 " Run: wire pair-confirm {} {}",
5118 t.get("code").and_then(Value::as_str).unwrap_or("?"),
5119 sas
5120 );
5121 }
5122 }
5123 }
5124 }
5125
5126 if once {
5127 return Ok(());
5128 }
5129 let _ = wake_rx.recv_timeout(interval);
5134 while wake_rx.try_recv().is_ok() {}
5135 }
5136}
5137
5138fn run_sync_push() -> Result<Value> {
5141 let state = config::read_relay_state()?;
5142 let peers = state["peers"].as_object().cloned().unwrap_or_default();
5143 if peers.is_empty() {
5144 return Ok(json!({"pushed": [], "skipped": []}));
5145 }
5146 let outbox_dir = config::outbox_dir()?;
5147 if !outbox_dir.exists() {
5148 return Ok(json!({"pushed": [], "skipped": []}));
5149 }
5150 let mut pushed = Vec::new();
5151 let mut skipped = Vec::new();
5152 for (peer_handle, slot_info) in peers.iter() {
5153 let outbox = outbox_dir.join(format!("{peer_handle}.jsonl"));
5154 if !outbox.exists() {
5155 continue;
5156 }
5157 let url = slot_info["relay_url"].as_str().unwrap_or("");
5158 let slot_id = slot_info["slot_id"].as_str().unwrap_or("");
5159 let slot_token = slot_info["slot_token"].as_str().unwrap_or("");
5160 if url.is_empty() || slot_id.is_empty() || slot_token.is_empty() {
5161 continue;
5162 }
5163 let client = crate::relay_client::RelayClient::new(url);
5164 let body = std::fs::read_to_string(&outbox)?;
5165 for line in body.lines() {
5166 let event: Value = match serde_json::from_str(line) {
5167 Ok(v) => v,
5168 Err(_) => continue,
5169 };
5170 let event_id = event
5171 .get("event_id")
5172 .and_then(Value::as_str)
5173 .unwrap_or("")
5174 .to_string();
5175 match client.post_event(slot_id, slot_token, &event) {
5176 Ok(resp) => {
5177 if resp.status == "duplicate" {
5178 skipped.push(json!({"peer": peer_handle, "event_id": event_id, "reason": "duplicate"}));
5179 } else {
5180 pushed.push(json!({"peer": peer_handle, "event_id": event_id}));
5181 }
5182 }
5183 Err(e) => {
5184 let reason = crate::relay_client::format_transport_error(&e);
5188 skipped
5189 .push(json!({"peer": peer_handle, "event_id": event_id, "reason": reason}));
5190 }
5191 }
5192 }
5193 }
5194 Ok(json!({"pushed": pushed, "skipped": skipped}))
5195}
5196
5197fn run_sync_pull() -> Result<Value> {
5205 let state = config::read_relay_state()?;
5206 let self_state = state.get("self").cloned().unwrap_or(Value::Null);
5207 if self_state.is_null() {
5208 return Ok(json!({"written": [], "rejected": [], "total_seen": 0}));
5209 }
5210 let ep = match crate::endpoints::self_primary_endpoint(&state) {
5211 Some(e) => e,
5212 None => return Ok(json!({"written": [], "rejected": [], "total_seen": 0})),
5213 };
5214 let url = ep.relay_url.as_str();
5215 let slot_id = ep.slot_id.as_str();
5216 let slot_token = ep.slot_token.as_str();
5217 let last_event_id = self_state
5218 .get("last_pulled_event_id")
5219 .and_then(Value::as_str)
5220 .map(str::to_string);
5221 if url.is_empty() {
5222 return Ok(json!({"written": [], "rejected": [], "total_seen": 0}));
5223 }
5224 let client = crate::relay_client::RelayClient::new(url);
5225 let events = client.list_events(slot_id, slot_token, last_event_id.as_deref(), Some(1000))?;
5226 let inbox_dir = config::inbox_dir()?;
5227 config::ensure_dirs()?;
5228
5229 let result = crate::pull::process_events(&events, last_event_id, &inbox_dir)?;
5233
5234 if let Some(eid) = &result.advance_cursor_to {
5236 let eid = eid.clone();
5237 config::update_relay_state(|state| {
5238 if let Some(self_obj) = state.get_mut("self").and_then(Value::as_object_mut) {
5239 self_obj.insert("last_pulled_event_id".into(), Value::String(eid));
5240 }
5241 Ok(())
5242 })?;
5243 }
5244
5245 Ok(json!({
5246 "written": result.written,
5247 "rejected": result.rejected,
5248 "total_seen": events.len(),
5249 "cursor_blocked": result.blocked,
5250 "cursor_advanced_to": result.advance_cursor_to,
5251 }))
5252}
5253
5254fn cmd_pin(card_file: &str, as_json: bool) -> Result<()> {
5257 let body =
5258 std::fs::read_to_string(card_file).with_context(|| format!("reading {card_file}"))?;
5259 let card: Value =
5260 serde_json::from_str(&body).with_context(|| format!("parsing {card_file}"))?;
5261 crate::agent_card::verify_agent_card(&card)
5262 .map_err(|e| anyhow!("peer card signature invalid: {e}"))?;
5263
5264 let mut trust = config::read_trust()?;
5265 crate::trust::add_agent_card_pin(&mut trust, &card, Some("VERIFIED"));
5266
5267 let did = card.get("did").and_then(Value::as_str).unwrap_or("");
5268 let handle = crate::agent_card::display_handle_from_did(did).to_string();
5269 config::write_trust(&trust)?;
5270
5271 if as_json {
5272 println!(
5273 "{}",
5274 serde_json::to_string(&json!({
5275 "handle": handle,
5276 "did": did,
5277 "tier": "VERIFIED",
5278 "pinned": true,
5279 }))?
5280 );
5281 } else {
5282 println!("pinned {handle} ({did}) at tier VERIFIED");
5283 }
5284 Ok(())
5285}
5286
5287fn cmd_pair_host(relay_url: &str, auto_yes: bool, timeout_secs: u64) -> Result<()> {
5290 pair_orchestrate(relay_url, None, "host", auto_yes, timeout_secs)
5291}
5292
5293fn cmd_pair_join(
5294 code_phrase: &str,
5295 relay_url: &str,
5296 auto_yes: bool,
5297 timeout_secs: u64,
5298) -> Result<()> {
5299 pair_orchestrate(
5300 relay_url,
5301 Some(code_phrase),
5302 "guest",
5303 auto_yes,
5304 timeout_secs,
5305 )
5306}
5307
5308fn pair_orchestrate(
5314 relay_url: &str,
5315 code_in: Option<&str>,
5316 role: &str,
5317 auto_yes: bool,
5318 timeout_secs: u64,
5319) -> Result<()> {
5320 use crate::pair_session::{pair_session_finalize, pair_session_open, pair_session_try_sas};
5321
5322 let mut s = pair_session_open(role, relay_url, code_in)?;
5323
5324 if role == "host" {
5325 eprintln!();
5326 eprintln!("share this code phrase with your peer:");
5327 eprintln!();
5328 eprintln!(" {}", s.code);
5329 eprintln!();
5330 eprintln!(
5331 "waiting for peer to run `wire pair-join {} --relay {relay_url}` ...",
5332 s.code
5333 );
5334 } else {
5335 eprintln!();
5336 eprintln!("joined pair-slot on {relay_url} — waiting for host's SPAKE2 message ...");
5337 }
5338
5339 const HEARTBEAT_SECS: u64 = 10;
5344 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
5345 let started = std::time::Instant::now();
5346 let mut last_heartbeat = started;
5347 let formatted = loop {
5348 if let Some(sas) = pair_session_try_sas(&mut s)? {
5349 break sas;
5350 }
5351 let now = std::time::Instant::now();
5352 if now >= deadline {
5353 return Err(anyhow!(
5354 "timeout after {timeout_secs}s waiting for peer's SPAKE2 message"
5355 ));
5356 }
5357 if now.duration_since(last_heartbeat).as_secs() >= HEARTBEAT_SECS {
5358 let elapsed = now.duration_since(started).as_secs();
5359 eprintln!(" ... still waiting ({elapsed}s / {timeout_secs}s)");
5360 last_heartbeat = now;
5361 }
5362 std::thread::sleep(std::time::Duration::from_millis(250));
5363 };
5364
5365 eprintln!();
5366 eprintln!("SAS digits (must match peer's terminal):");
5367 eprintln!();
5368 eprintln!(" {formatted}");
5369 eprintln!();
5370
5371 if !auto_yes {
5374 eprint!("does this match your peer's terminal? [y/N]: ");
5375 use std::io::Write;
5376 std::io::stderr().flush().ok();
5377 let mut input = String::new();
5378 std::io::stdin().read_line(&mut input)?;
5379 let trimmed = input.trim().to_lowercase();
5380 if trimmed != "y" && trimmed != "yes" {
5381 bail!("SAS confirmation declined — aborting pairing");
5382 }
5383 }
5384 s.sas_confirmed = true;
5385
5386 let result = pair_session_finalize(&mut s, timeout_secs)?;
5388
5389 let peer_did = result["paired_with"].as_str().unwrap_or("");
5390 let peer_role = if role == "host" { "guest" } else { "host" };
5391 eprintln!("paired with {peer_did} (peer role: {peer_role})");
5392 eprintln!("peer card pinned at tier VERIFIED");
5393 eprintln!(
5394 "peer relay slot saved to {}",
5395 config::relay_state_path()?.display()
5396 );
5397
5398 println!("{}", serde_json::to_string(&result)?);
5399 Ok(())
5400}
5401
5402fn cmd_pair(
5408 handle: &str,
5409 code: Option<&str>,
5410 relay: &str,
5411 auto_yes: bool,
5412 timeout_secs: u64,
5413 no_setup: bool,
5414) -> Result<()> {
5415 let init_result = crate::pair_session::init_self_idempotent(handle, None, None)?;
5418 let did = init_result
5419 .get("did")
5420 .and_then(|v| v.as_str())
5421 .unwrap_or("(unknown)")
5422 .to_string();
5423 let already = init_result
5424 .get("already_initialized")
5425 .and_then(|v| v.as_bool())
5426 .unwrap_or(false);
5427 if already {
5428 println!("(identity {did} already initialized — reusing)");
5429 } else {
5430 println!("initialized {did}");
5431 }
5432 println!();
5433
5434 match code {
5436 None => {
5437 println!("hosting pair on {relay} (no code = host) ...");
5438 cmd_pair_host(relay, auto_yes, timeout_secs)?;
5439 }
5440 Some(c) => {
5441 println!("joining pair with code {c} on {relay} ...");
5442 cmd_pair_join(c, relay, auto_yes, timeout_secs)?;
5443 }
5444 }
5445
5446 if !no_setup {
5448 println!();
5449 println!("registering wire as MCP server in detected client configs ...");
5450 if let Err(e) = cmd_setup(true) {
5451 eprintln!("warn: setup --apply failed: {e}");
5453 eprintln!(" pair succeeded; you can re-run `wire setup --apply` manually.");
5454 }
5455 }
5456
5457 println!();
5458 println!("pair complete. Next steps:");
5459 println!(" wire daemon start # background sync of inbox/outbox vs relay");
5460 println!(" wire send <peer> claim <msg> # send your peer something");
5461 println!(" wire tail # watch incoming events");
5462 Ok(())
5463}
5464
5465fn cmd_pair_detach(handle: &str, code: Option<&str>, relay: &str) -> Result<()> {
5471 let init_result = crate::pair_session::init_self_idempotent(handle, None, None)?;
5472 let did = init_result
5473 .get("did")
5474 .and_then(|v| v.as_str())
5475 .unwrap_or("(unknown)")
5476 .to_string();
5477 let already = init_result
5478 .get("already_initialized")
5479 .and_then(|v| v.as_bool())
5480 .unwrap_or(false);
5481 if already {
5482 println!("(identity {did} already initialized — reusing)");
5483 } else {
5484 println!("initialized {did}");
5485 }
5486 println!();
5487 match code {
5488 None => cmd_pair_host_detach(relay, false),
5489 Some(c) => cmd_pair_join_detach(c, relay, false),
5490 }
5491}
5492
5493fn cmd_pair_host_detach(relay_url: &str, as_json: bool) -> Result<()> {
5494 if !config::is_initialized()? {
5495 bail!("not initialized — run `wire init <handle>` first");
5496 }
5497 let daemon_spawned = match crate::ensure_up::ensure_daemon_running() {
5498 Ok(b) => b,
5499 Err(e) => {
5500 if !as_json {
5501 eprintln!(
5502 "warn: could not auto-start daemon: {e}; pair will queue but not advance"
5503 );
5504 }
5505 false
5506 }
5507 };
5508 let code = crate::sas::generate_code_phrase();
5509 let code_hash = crate::pair_session::derive_code_hash(&code);
5510 let now = time::OffsetDateTime::now_utc()
5511 .format(&time::format_description::well_known::Rfc3339)
5512 .unwrap_or_default();
5513 let p = crate::pending_pair::PendingPair {
5514 code: code.clone(),
5515 code_hash,
5516 role: "host".to_string(),
5517 relay_url: relay_url.to_string(),
5518 status: "request_host".to_string(),
5519 sas: None,
5520 peer_did: None,
5521 created_at: now,
5522 last_error: None,
5523 pair_id: None,
5524 our_slot_id: None,
5525 our_slot_token: None,
5526 spake2_seed_b64: None,
5527 };
5528 crate::pending_pair::write_pending(&p)?;
5529 if as_json {
5530 println!(
5531 "{}",
5532 serde_json::to_string(&json!({
5533 "state": "queued",
5534 "code_phrase": code,
5535 "relay_url": relay_url,
5536 "role": "host",
5537 "daemon_spawned": daemon_spawned,
5538 }))?
5539 );
5540 } else {
5541 if daemon_spawned {
5542 println!("(started wire daemon in background)");
5543 }
5544 println!("detached pair-host queued. Share this code with your peer:\n");
5545 println!(" {code}\n");
5546 println!("Next steps:");
5547 println!(" wire pair-list # check status");
5548 println!(" wire pair-confirm {code} <digits> # when SAS shows up");
5549 println!(" wire pair-cancel {code} # to abort");
5550 }
5551 Ok(())
5552}
5553
5554fn cmd_pair_join_detach(code_phrase: &str, relay_url: &str, as_json: bool) -> Result<()> {
5555 if !config::is_initialized()? {
5556 bail!("not initialized — run `wire init <handle>` first");
5557 }
5558 let daemon_spawned = match crate::ensure_up::ensure_daemon_running() {
5559 Ok(b) => b,
5560 Err(e) => {
5561 if !as_json {
5562 eprintln!(
5563 "warn: could not auto-start daemon: {e}; pair will queue but not advance"
5564 );
5565 }
5566 false
5567 }
5568 };
5569 let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
5570 let code_hash = crate::pair_session::derive_code_hash(&code);
5571 let now = time::OffsetDateTime::now_utc()
5572 .format(&time::format_description::well_known::Rfc3339)
5573 .unwrap_or_default();
5574 let p = crate::pending_pair::PendingPair {
5575 code: code.clone(),
5576 code_hash,
5577 role: "guest".to_string(),
5578 relay_url: relay_url.to_string(),
5579 status: "request_guest".to_string(),
5580 sas: None,
5581 peer_did: None,
5582 created_at: now,
5583 last_error: None,
5584 pair_id: None,
5585 our_slot_id: None,
5586 our_slot_token: None,
5587 spake2_seed_b64: None,
5588 };
5589 crate::pending_pair::write_pending(&p)?;
5590 if as_json {
5591 println!(
5592 "{}",
5593 serde_json::to_string(&json!({
5594 "state": "queued",
5595 "code_phrase": code,
5596 "relay_url": relay_url,
5597 "role": "guest",
5598 "daemon_spawned": daemon_spawned,
5599 }))?
5600 );
5601 } else {
5602 if daemon_spawned {
5603 println!("(started wire daemon in background)");
5604 }
5605 println!("detached pair-join queued for code {code}.");
5606 println!(
5607 "Run `wire pair-list` to watch for SAS, then `wire pair-confirm {code} <digits>`."
5608 );
5609 }
5610 Ok(())
5611}
5612
5613fn cmd_pair_confirm(code_phrase: &str, typed_digits: &str, as_json: bool) -> Result<()> {
5614 let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
5615 let typed: String = typed_digits
5616 .chars()
5617 .filter(|c| c.is_ascii_digit())
5618 .collect();
5619 if typed.len() != 6 {
5620 bail!(
5621 "expected 6 digits (got {} after stripping non-digits)",
5622 typed.len()
5623 );
5624 }
5625 let mut p = crate::pending_pair::read_pending(&code)?
5626 .ok_or_else(|| anyhow!("no pending pair found for code {code}"))?;
5627 if p.status != "sas_ready" {
5628 bail!(
5629 "pair {code} not in sas_ready state (current: {}). Run `wire pair-list` to see what's going on.",
5630 p.status
5631 );
5632 }
5633 let stored = p
5634 .sas
5635 .as_ref()
5636 .ok_or_else(|| anyhow!("pending file has status=sas_ready but no sas field"))?
5637 .clone();
5638 if stored == typed {
5639 p.status = "confirmed".to_string();
5640 crate::pending_pair::write_pending(&p)?;
5641 if as_json {
5642 println!(
5643 "{}",
5644 serde_json::to_string(&json!({
5645 "state": "confirmed",
5646 "code_phrase": code,
5647 }))?
5648 );
5649 } else {
5650 println!("digits match. Daemon will finalize the handshake on its next tick.");
5651 println!("Run `wire peers` after a few seconds to confirm.");
5652 }
5653 } else {
5654 p.status = "aborted".to_string();
5655 p.last_error = Some(format!(
5656 "SAS digit mismatch (typed {typed}, expected {stored})"
5657 ));
5658 let client = crate::relay_client::RelayClient::new(&p.relay_url);
5659 let _ = client.pair_abandon(&p.code_hash);
5660 crate::pending_pair::write_pending(&p)?;
5661 crate::os_notify::toast(
5662 &format!("wire — pair aborted ({})", p.code),
5663 p.last_error.as_deref().unwrap_or("digits mismatch"),
5664 );
5665 if as_json {
5666 println!(
5667 "{}",
5668 serde_json::to_string(&json!({
5669 "state": "aborted",
5670 "code_phrase": code,
5671 "error": "digits mismatch",
5672 }))?
5673 );
5674 }
5675 bail!("digits mismatch — pair aborted. Re-issue with a fresh `wire pair-host --detach`.");
5676 }
5677 Ok(())
5678}
5679
5680fn cmd_pair_list(as_json: bool, watch: bool, watch_interval_secs: u64) -> Result<()> {
5681 if watch {
5682 return cmd_pair_list_watch(watch_interval_secs);
5683 }
5684 let spake2_items = crate::pending_pair::list_pending()?;
5685 let inbound_items = crate::pending_inbound_pair::list_pending_inbound()?;
5686 if as_json {
5687 println!("{}", serde_json::to_string(&spake2_items)?);
5692 return Ok(());
5693 }
5694 if spake2_items.is_empty() && inbound_items.is_empty() {
5695 println!("no pending pair sessions.");
5696 return Ok(());
5697 }
5698 if !inbound_items.is_empty() {
5701 println!("PENDING INBOUND (v0.5.14 zero-paste pair_drop awaiting your accept)");
5702 println!(
5703 "{:<20} {:<35} {:<25} NEXT STEP",
5704 "PEER", "RELAY", "RECEIVED"
5705 );
5706 for p in &inbound_items {
5707 println!(
5708 "{:<20} {:<35} {:<25} `wire pair-accept {peer}` to accept; `wire pair-reject {peer}` to refuse",
5709 p.peer_handle,
5710 p.peer_relay_url,
5711 p.received_at,
5712 peer = p.peer_handle,
5713 );
5714 }
5715 println!();
5716 }
5717 if !spake2_items.is_empty() {
5718 println!("SPAKE2 SESSIONS");
5719 println!(
5720 "{:<15} {:<8} {:<18} {:<10} NOTE",
5721 "CODE", "ROLE", "STATUS", "SAS"
5722 );
5723 for p in spake2_items {
5724 let sas = p
5725 .sas
5726 .as_ref()
5727 .map(|d| format!("{}-{}", &d[..3], &d[3..]))
5728 .unwrap_or_else(|| "—".to_string());
5729 let note = p
5730 .last_error
5731 .as_deref()
5732 .or(p.peer_did.as_deref())
5733 .unwrap_or("");
5734 println!(
5735 "{:<15} {:<8} {:<18} {:<10} {}",
5736 p.code, p.role, p.status, sas, note
5737 );
5738 }
5739 }
5740 Ok(())
5741}
5742
5743fn cmd_pair_list_watch(interval_secs: u64) -> Result<()> {
5755 use std::collections::HashMap;
5756 use std::io::Write;
5757 let interval = std::time::Duration::from_secs(interval_secs.max(1));
5758 let mut prev: HashMap<String, String> = HashMap::new();
5761 {
5762 let items = crate::pending_pair::list_pending()?;
5763 for p in &items {
5764 println!("{}", serde_json::to_string(&p)?);
5765 prev.insert(p.code.clone(), p.status.clone());
5766 }
5767 let _ = std::io::stdout().flush();
5769 }
5770 loop {
5771 std::thread::sleep(interval);
5772 let items = match crate::pending_pair::list_pending() {
5773 Ok(v) => v,
5774 Err(_) => continue,
5775 };
5776 let mut cur: HashMap<String, String> = HashMap::new();
5777 for p in &items {
5778 cur.insert(p.code.clone(), p.status.clone());
5779 match prev.get(&p.code) {
5780 None => {
5781 println!("{}", serde_json::to_string(&p)?);
5783 }
5784 Some(prev_status) if prev_status != &p.status => {
5785 println!("{}", serde_json::to_string(&p)?);
5787 }
5788 _ => {}
5789 }
5790 }
5791 for code in prev.keys() {
5792 if !cur.contains_key(code) {
5793 println!(
5796 "{}",
5797 serde_json::to_string(&json!({
5798 "code": code,
5799 "status": "removed",
5800 "_synthetic": true,
5801 }))?
5802 );
5803 }
5804 }
5805 let _ = std::io::stdout().flush();
5806 prev = cur;
5807 }
5808}
5809
5810fn cmd_pair_watch(
5814 code_phrase: &str,
5815 target_status: &str,
5816 timeout_secs: u64,
5817 as_json: bool,
5818) -> Result<()> {
5819 let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
5820 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
5821 let mut last_seen_status: Option<String> = None;
5822 loop {
5823 let p_opt = crate::pending_pair::read_pending(&code)?;
5824 let now = std::time::Instant::now();
5825 match p_opt {
5826 None => {
5827 if last_seen_status.is_some() {
5831 if as_json {
5832 println!(
5833 "{}",
5834 serde_json::to_string(&json!({"state": "finalized", "code": code}))?
5835 );
5836 } else {
5837 println!("pair {code} finalized (file removed)");
5838 }
5839 return Ok(());
5840 } else {
5841 if as_json {
5842 println!(
5843 "{}",
5844 serde_json::to_string(&json!({"error": "no such pair", "code": code}))?
5845 );
5846 }
5847 std::process::exit(1);
5848 }
5849 }
5850 Some(p) => {
5851 let cur = p.status.clone();
5852 if Some(cur.clone()) != last_seen_status {
5853 if as_json {
5854 println!("{}", serde_json::to_string(&p)?);
5856 }
5857 last_seen_status = Some(cur.clone());
5858 }
5859 if cur == target_status {
5860 if !as_json {
5861 let sas_str = p
5862 .sas
5863 .as_ref()
5864 .map(|s| format!("{}-{}", &s[..3], &s[3..]))
5865 .unwrap_or_else(|| "—".to_string());
5866 println!("pair {code} reached {target_status} (SAS: {sas_str})");
5867 }
5868 return Ok(());
5869 }
5870 if cur == "aborted" || cur == "aborted_restart" {
5871 if !as_json {
5872 let err = p.last_error.as_deref().unwrap_or("(no detail)");
5873 eprintln!("pair {code} {cur}: {err}");
5874 }
5875 std::process::exit(1);
5876 }
5877 }
5878 }
5879 if now >= deadline {
5880 if !as_json {
5881 eprintln!(
5882 "timeout after {timeout_secs}s waiting for pair {code} to reach {target_status}"
5883 );
5884 }
5885 std::process::exit(2);
5886 }
5887 std::thread::sleep(std::time::Duration::from_millis(250));
5888 }
5889}
5890
5891fn cmd_pair_cancel(code_phrase: &str, as_json: bool) -> Result<()> {
5892 let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
5893 let p = crate::pending_pair::read_pending(&code)?
5894 .ok_or_else(|| anyhow!("no pending pair for code {code}"))?;
5895 let client = crate::relay_client::RelayClient::new(&p.relay_url);
5896 let _ = client.pair_abandon(&p.code_hash);
5897 crate::pending_pair::delete_pending(&code)?;
5898 if as_json {
5899 println!(
5900 "{}",
5901 serde_json::to_string(&json!({
5902 "state": "cancelled",
5903 "code_phrase": code,
5904 }))?
5905 );
5906 } else {
5907 println!("cancelled pending pair {code} (relay slot released, file removed).");
5908 }
5909 Ok(())
5910}
5911
5912fn cmd_pair_abandon(code_phrase: &str, relay_url: &str) -> Result<()> {
5915 let code = crate::sas::parse_code_phrase(code_phrase)?;
5918 let code_hash = crate::pair_session::derive_code_hash(code);
5919 let client = crate::relay_client::RelayClient::new(relay_url);
5920 client.pair_abandon(&code_hash)?;
5921 println!("abandoned pair-slot for code {code_phrase} on {relay_url}");
5922 println!("host can now issue a fresh code; guest can re-join.");
5923 Ok(())
5924}
5925
5926fn cmd_invite(relay: &str, ttl: u64, uses: u32, share: bool, as_json: bool) -> Result<()> {
5929 let url = crate::pair_invite::mint_invite(Some(ttl), uses, Some(relay))?;
5930
5931 let share_payload: Option<Value> = if share {
5934 let client = reqwest::blocking::Client::new();
5935 let single_use = if uses == 1 { Some(1u32) } else { None };
5936 let body = json!({
5937 "invite_url": url,
5938 "ttl_seconds": ttl,
5939 "uses": single_use,
5940 });
5941 let endpoint = format!("{}/v1/invite/register", relay.trim_end_matches('/'));
5942 let resp = client.post(&endpoint).json(&body).send()?;
5943 if !resp.status().is_success() {
5944 let code = resp.status();
5945 let txt = resp.text().unwrap_or_default();
5946 bail!("relay {code} on /v1/invite/register: {txt}");
5947 }
5948 let parsed: Value = resp.json()?;
5949 let token = parsed
5950 .get("token")
5951 .and_then(Value::as_str)
5952 .ok_or_else(|| anyhow::anyhow!("relay reply missing token"))?
5953 .to_string();
5954 let share_url = format!("{}/i/{}", relay.trim_end_matches('/'), token);
5955 let curl_line = format!("curl -fsSL {share_url} | sh");
5956 Some(json!({
5957 "token": token,
5958 "share_url": share_url,
5959 "curl": curl_line,
5960 "expires_unix": parsed.get("expires_unix"),
5961 }))
5962 } else {
5963 None
5964 };
5965
5966 if as_json {
5967 let mut out = json!({
5968 "invite_url": url,
5969 "ttl_secs": ttl,
5970 "uses": uses,
5971 "relay": relay,
5972 });
5973 if let Some(s) = &share_payload {
5974 out["share"] = s.clone();
5975 }
5976 println!("{}", serde_json::to_string(&out)?);
5977 } else if let Some(s) = share_payload {
5978 let curl = s.get("curl").and_then(Value::as_str).unwrap_or("");
5979 eprintln!("# One-curl onboarding. Share this single line — installs wire if missing,");
5980 eprintln!("# accepts the invite, pairs both sides. TTL: {ttl}s. Uses: {uses}.");
5981 println!("{curl}");
5982 } else {
5983 eprintln!("# Share this URL with one peer. Pasting it = pair complete on their side.");
5984 eprintln!("# TTL: {ttl}s. Uses: {uses}.");
5985 println!("{url}");
5986 }
5987 Ok(())
5988}
5989
5990fn cmd_accept(url: &str, as_json: bool) -> Result<()> {
5991 let resolved = if url.starts_with("http://") || url.starts_with("https://") {
5995 let sep = if url.contains('?') { '&' } else { '?' };
5996 let resolve_url = format!("{url}{sep}format=url");
5997 let client = reqwest::blocking::Client::new();
5998 let resp = client
5999 .get(&resolve_url)
6000 .send()
6001 .with_context(|| format!("GET {resolve_url}"))?;
6002 if !resp.status().is_success() {
6003 bail!("could not resolve short URL {url} (HTTP {})", resp.status());
6004 }
6005 let body = resp.text().unwrap_or_default().trim().to_string();
6006 if !body.starts_with("wire://pair?") {
6007 bail!(
6008 "short URL {url} did not resolve to a wire:// invite. \
6009 (got: {}{})",
6010 body.chars().take(80).collect::<String>(),
6011 if body.chars().count() > 80 { "…" } else { "" }
6012 );
6013 }
6014 body
6015 } else {
6016 url.to_string()
6017 };
6018
6019 let result = crate::pair_invite::accept_invite(&resolved)?;
6020 if as_json {
6021 println!("{}", serde_json::to_string(&result)?);
6022 } else {
6023 let did = result
6024 .get("paired_with")
6025 .and_then(Value::as_str)
6026 .unwrap_or("?");
6027 println!("paired with {did}");
6028 println!(
6029 "you can now: wire send {} <kind> <body>",
6030 crate::agent_card::display_handle_from_did(did)
6031 );
6032 }
6033 Ok(())
6034}
6035
6036fn cmd_whois(handle: Option<&str>, as_json: bool, relay_override: Option<&str>) -> Result<()> {
6039 if let Some(h) = handle {
6040 let parsed = crate::pair_profile::parse_handle(h)?;
6041 if config::is_initialized()? {
6044 let card = config::read_agent_card()?;
6045 let local_handle = card
6046 .get("profile")
6047 .and_then(|p| p.get("handle"))
6048 .and_then(Value::as_str)
6049 .map(str::to_string);
6050 if local_handle.as_deref() == Some(h) {
6051 return cmd_whois(None, as_json, None);
6052 }
6053 }
6054 let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)?;
6056 if as_json {
6057 println!("{}", serde_json::to_string(&resolved)?);
6058 } else {
6059 print_resolved_profile(&resolved);
6060 }
6061 return Ok(());
6062 }
6063 let card = config::read_agent_card()?;
6064 if as_json {
6065 let profile = card.get("profile").cloned().unwrap_or(Value::Null);
6066 println!(
6067 "{}",
6068 serde_json::to_string(&json!({
6069 "did": card.get("did").cloned().unwrap_or(Value::Null),
6070 "profile": profile,
6071 }))?
6072 );
6073 } else {
6074 print!("{}", crate::pair_profile::render_self_summary()?);
6075 }
6076 Ok(())
6077}
6078
6079fn print_resolved_profile(resolved: &Value) {
6080 let did = resolved.get("did").and_then(Value::as_str).unwrap_or("?");
6081 let nick = resolved.get("nick").and_then(Value::as_str).unwrap_or("?");
6082 let relay = resolved
6083 .get("relay_url")
6084 .and_then(Value::as_str)
6085 .unwrap_or("");
6086 let slot = resolved
6087 .get("slot_id")
6088 .and_then(Value::as_str)
6089 .unwrap_or("");
6090 let profile = resolved
6091 .get("card")
6092 .and_then(|c| c.get("profile"))
6093 .cloned()
6094 .unwrap_or(Value::Null);
6095 println!("{did}");
6096 println!(" nick: {nick}");
6097 if !relay.is_empty() {
6098 println!(" relay_url: {relay}");
6099 }
6100 if !slot.is_empty() {
6101 println!(" slot_id: {slot}");
6102 }
6103 let pick =
6104 |k: &str| -> Option<String> { profile.get(k).and_then(Value::as_str).map(str::to_string) };
6105 if let Some(s) = pick("display_name") {
6106 println!(" display_name: {s}");
6107 }
6108 if let Some(s) = pick("emoji") {
6109 println!(" emoji: {s}");
6110 }
6111 if let Some(s) = pick("motto") {
6112 println!(" motto: {s}");
6113 }
6114 if let Some(arr) = profile.get("vibe").and_then(Value::as_array) {
6115 let joined: Vec<String> = arr
6116 .iter()
6117 .filter_map(|v| v.as_str().map(str::to_string))
6118 .collect();
6119 println!(" vibe: {}", joined.join(", "));
6120 }
6121 if let Some(s) = pick("pronouns") {
6122 println!(" pronouns: {s}");
6123 }
6124}
6125
6126fn host_of_url(url: &str) -> String {
6134 let no_scheme = url
6135 .trim_start_matches("https://")
6136 .trim_start_matches("http://");
6137 no_scheme
6138 .split('/')
6139 .next()
6140 .unwrap_or("")
6141 .split(':')
6142 .next()
6143 .unwrap_or("")
6144 .to_string()
6145}
6146
6147fn is_known_relay_domain(peer_domain: &str, our_relay_url: &str) -> bool {
6151 const KNOWN_GOOD: &[&str] = &["wireup.net", "wire.laulpogan.com"];
6153 let peer_domain = peer_domain.trim().to_ascii_lowercase();
6154 if KNOWN_GOOD.iter().any(|k| *k == peer_domain) {
6155 return true;
6156 }
6157 let our_host = host_of_url(our_relay_url).to_ascii_lowercase();
6160 if !our_host.is_empty() && our_host == peer_domain {
6161 return true;
6162 }
6163 false
6164}
6165
6166fn resolve_local_session<'a>(
6184 sessions: &'a [crate::session::SessionInfo],
6185 input: &str,
6186) -> Result<&'a crate::session::SessionInfo, ResolveError> {
6187 if let Some(s) = sessions.iter().find(|s| s.name == input) {
6190 return Ok(s);
6191 }
6192 let nick_matches: Vec<&crate::session::SessionInfo> = sessions
6193 .iter()
6194 .filter(|s| {
6195 s.character
6196 .as_ref()
6197 .map(|c| c.nickname == input)
6198 .unwrap_or(false)
6199 })
6200 .collect();
6201 match nick_matches.len() {
6202 0 => Err(ResolveError::NotFound),
6203 1 => Ok(nick_matches[0]),
6204 _ => Err(ResolveError::Ambiguous(
6205 nick_matches.iter().map(|s| s.name.clone()).collect(),
6206 )),
6207 }
6208}
6209
6210#[derive(Debug)]
6211enum ResolveError {
6212 NotFound,
6213 Ambiguous(Vec<String>),
6214}
6215
6216fn resolve_peer_handle(input: &str) -> Result<Option<String>, ResolveError> {
6232 let trust = match config::read_trust() {
6233 Ok(t) => t,
6234 Err(_) => return Ok(None),
6235 };
6236 let agents = match trust.get("agents").and_then(|a| a.as_object()) {
6237 Some(a) => a,
6238 None => return Ok(None),
6239 };
6240 if agents.contains_key(input) {
6241 return Ok(Some(input.to_string()));
6242 }
6243 let mut nick_matches: Vec<String> = Vec::new();
6244 for (handle, agent) in agents.iter() {
6245 let character = match agent.get("card") {
6249 Some(card) => crate::character::Character::from_card(card),
6250 None => match agent.get("did").and_then(Value::as_str) {
6251 Some(did) => crate::character::Character::from_did(did),
6252 None => continue,
6253 },
6254 };
6255 if character.nickname == input {
6256 nick_matches.push(handle.clone());
6257 }
6258 }
6259 match nick_matches.len() {
6260 0 => Ok(None),
6261 1 => Ok(Some(nick_matches.into_iter().next().unwrap())),
6262 _ => Err(ResolveError::Ambiguous(nick_matches)),
6263 }
6264}
6265
6266fn cmd_add_local_sister(sister_name: &str, as_json: bool) -> Result<()> {
6267 let sessions = crate::session::list_sessions()?;
6269 let sister = match resolve_local_session(&sessions, sister_name) {
6270 Ok(s) => s,
6271 Err(ResolveError::NotFound) => bail!(
6272 "no sister session named `{sister_name}` (matched by session name or character nickname). \
6273 Run `wire session list` to see what's available."
6274 ),
6275 Err(ResolveError::Ambiguous(candidates)) => bail!(
6276 "nickname `{sister_name}` is ambiguous — matches {} sessions: {}. \
6277 Disambiguate by passing the session name (one of those listed) instead of the nickname.",
6278 candidates.len(),
6279 candidates.join(", ")
6280 ),
6281 };
6282 if sister.name != sister_name {
6285 eprintln!(
6286 "wire add: resolved nickname `{sister_name}` → session `{}`",
6287 sister.name
6288 );
6289 }
6290
6291 let our_card = config::read_agent_card()
6294 .map_err(|_| anyhow!("not initialized — run `wire init <handle>` first"))?;
6295 let our_did = our_card
6296 .get("did")
6297 .and_then(Value::as_str)
6298 .ok_or_else(|| anyhow!("agent-card missing did"))?
6299 .to_string();
6300 if let Some(sister_did) = sister.did.as_deref()
6301 && sister_did == our_did
6302 {
6303 bail!("refusing to add self (`{sister_name}` is this very session)");
6304 }
6305
6306 let sister_card_path = sister
6308 .home_dir
6309 .join("config")
6310 .join("wire")
6311 .join("agent-card.json");
6312 let sister_card: Value = serde_json::from_slice(
6313 &std::fs::read(&sister_card_path)
6314 .with_context(|| format!("reading sister card {sister_card_path:?}"))?,
6315 )
6316 .with_context(|| format!("parsing sister card {sister_card_path:?}"))?;
6317 let sister_relay_state: Value = std::fs::read(
6318 sister
6319 .home_dir
6320 .join("config")
6321 .join("wire")
6322 .join("relay.json"),
6323 )
6324 .ok()
6325 .and_then(|b| serde_json::from_slice(&b).ok())
6326 .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
6327
6328 let sister_did = sister_card
6329 .get("did")
6330 .and_then(Value::as_str)
6331 .ok_or_else(|| anyhow!("sister card missing did"))?
6332 .to_string();
6333 let sister_handle = crate::agent_card::display_handle_from_did(&sister_did).to_string();
6334
6335 let sister_endpoints = crate::endpoints::self_endpoints(&sister_relay_state);
6339 if sister_endpoints.is_empty() {
6340 bail!(
6341 "sister `{sister_name}` has no endpoints in its relay.json — recreate with `wire session new --local-only` or `--with-local`"
6342 );
6343 }
6344 let sister_local = sister_endpoints
6345 .iter()
6346 .find(|e| e.scope == crate::endpoints::EndpointScope::Local);
6347 let delivery_endpoint = match sister_local {
6348 Some(e) => e.clone(),
6349 None => sister_endpoints[0].clone(),
6350 };
6351
6352 let our_relay_state = config::read_relay_state()?;
6358 let our_endpoints = crate::endpoints::self_endpoints(&our_relay_state);
6359 if our_endpoints.is_empty() {
6360 bail!(
6361 "this session has no endpoints — run `wire session new --local-only` or `wire bind-relay` first"
6362 );
6363 }
6364 let our_advertised = our_endpoints
6365 .iter()
6366 .find(|e| e.scope == crate::endpoints::EndpointScope::Federation)
6367 .cloned()
6368 .unwrap_or_else(|| our_endpoints[0].clone());
6369
6370 let mut trust = config::read_trust()?;
6374 crate::trust::add_agent_card_pin(&mut trust, &sister_card, Some("VERIFIED"));
6375 config::write_trust(&trust)?;
6376 let mut relay_state = config::read_relay_state()?;
6377 crate::endpoints::pin_peer_endpoints(&mut relay_state, &sister_handle, &sister_endpoints)?;
6378 config::write_relay_state(&relay_state)?;
6379
6380 let sk_seed = config::read_private_key()?;
6383 let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
6384 let pk_b64 = our_card
6385 .get("verify_keys")
6386 .and_then(Value::as_object)
6387 .and_then(|m| m.values().next())
6388 .and_then(|v| v.get("key"))
6389 .and_then(Value::as_str)
6390 .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
6391 let pk_bytes = crate::signing::b64decode(pk_b64)?;
6392 let now = time::OffsetDateTime::now_utc()
6393 .format(&time::format_description::well_known::Rfc3339)
6394 .unwrap_or_default();
6395 let mut body = json!({
6396 "card": our_card,
6397 "relay_url": our_advertised.relay_url,
6398 "slot_id": our_advertised.slot_id,
6399 "slot_token": our_advertised.slot_token,
6400 });
6401 body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
6402 let event = json!({
6403 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
6404 "timestamp": now,
6405 "from": our_did,
6406 "to": sister_did,
6407 "type": "pair_drop",
6408 "kind": 1100u32,
6409 "body": body,
6410 });
6411 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
6412 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
6413
6414 let client = crate::relay_client::RelayClient::new(&delivery_endpoint.relay_url);
6418 client
6419 .post_event(
6420 &delivery_endpoint.slot_id,
6421 &delivery_endpoint.slot_token,
6422 &signed,
6423 )
6424 .with_context(|| format!("delivering pair_drop to `{sister_name}`'s local slot"))?;
6425
6426 if as_json {
6427 println!(
6428 "{}",
6429 serde_json::to_string(&json!({
6430 "handle": sister_name,
6431 "paired_with": sister_did,
6432 "peer_handle": sister_handle,
6433 "event_id": event_id,
6434 "delivered_via": match delivery_endpoint.scope {
6435 crate::endpoints::EndpointScope::Local => "local",
6436 crate::endpoints::EndpointScope::Lan => "lan",
6437 crate::endpoints::EndpointScope::Uds => "uds",
6438 crate::endpoints::EndpointScope::Federation => "federation",
6439 },
6440 "status": "drop_sent",
6441 }))?
6442 );
6443 } else {
6444 let scope = match delivery_endpoint.scope {
6445 crate::endpoints::EndpointScope::Local => "local",
6446 crate::endpoints::EndpointScope::Lan => "lan",
6447 crate::endpoints::EndpointScope::Uds => "uds",
6448 crate::endpoints::EndpointScope::Federation => "federation",
6449 };
6450 println!(
6451 "→ 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.",
6452 delivery_endpoint.relay_url
6453 );
6454 }
6455 Ok(())
6456}
6457
6458fn cmd_add(
6459 handle_arg: &str,
6460 relay_override: Option<&str>,
6461 local_sister: bool,
6462 as_json: bool,
6463) -> Result<()> {
6464 if local_sister {
6472 let resolved = crate::session::resolve_local_sister(handle_arg)
6473 .unwrap_or_else(|| handle_arg.to_string());
6474 return cmd_add_local_sister(&resolved, as_json);
6475 }
6476 if !handle_arg.contains('@')
6477 && let Some(resolved) = crate::session::resolve_local_sister(handle_arg)
6478 {
6479 eprintln!(
6480 "wire add: `{handle_arg}` resolved to local sister session `{resolved}` \
6481 — routing via --local-sister (disk-read card, no relay lookup)."
6482 );
6483 return cmd_add_local_sister(&resolved, as_json);
6484 }
6485 if !handle_arg.contains('@') {
6486 bail!(
6487 "`{handle_arg}` doesn't match any local sister session and has no \
6488 @<relay> suffix for federation.\n\
6489 — Local sisters: `wire session list-local` (operator types name OR \
6490 character nickname)\n\
6491 — Federation: `wire add <handle>@<relay-domain>` (e.g. \
6492 `wire add alice@wireup.net`)"
6493 );
6494 }
6495 let parsed = crate::pair_profile::parse_handle(handle_arg)?;
6496
6497 let (our_did, our_relay, our_slot_id, our_slot_token) =
6499 crate::pair_invite::ensure_self_with_relay(relay_override)?;
6500 if our_did == format!("did:wire:{}", parsed.nick) {
6501 bail!("refusing to add self (handle matches own DID)");
6503 }
6504
6505 if let Some(pending) = crate::pending_inbound_pair::read_pending_inbound(&parsed.nick)? {
6515 return cmd_add_accept_pending(
6516 handle_arg,
6517 &parsed.nick,
6518 &pending,
6519 &our_relay,
6520 &our_slot_id,
6521 &our_slot_token,
6522 as_json,
6523 );
6524 }
6525
6526 if !is_known_relay_domain(&parsed.domain, &our_relay) {
6543 eprintln!(
6544 "wire add: WARN unfamiliar relay domain `{}`.",
6545 parsed.domain
6546 );
6547 eprintln!(
6548 " This is NOT `wireup.net` (the default), NOT your own relay (`{}`), ",
6549 host_of_url(&our_relay)
6550 );
6551 eprintln!(
6552 " and not on the known-good list. If you meant `{}@wireup.net`, ",
6553 parsed.nick
6554 );
6555 eprintln!(
6556 " run `wire add {}@wireup.net` instead. Otherwise verify with your",
6557 parsed.nick
6558 );
6559 eprintln!(" peer out-of-band that they actually run a relay at this domain");
6560 eprintln!(" before relying on the pair. (See issue #9.4.)");
6561 }
6562
6563 let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)?;
6565 let peer_card = resolved
6566 .get("card")
6567 .cloned()
6568 .ok_or_else(|| anyhow!("resolved missing card"))?;
6569 let peer_did = resolved
6570 .get("did")
6571 .and_then(Value::as_str)
6572 .ok_or_else(|| anyhow!("resolved missing did"))?
6573 .to_string();
6574 let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
6575 let peer_slot_id = resolved
6576 .get("slot_id")
6577 .and_then(Value::as_str)
6578 .ok_or_else(|| anyhow!("resolved missing slot_id"))?
6579 .to_string();
6580 let peer_relay = resolved
6581 .get("relay_url")
6582 .and_then(Value::as_str)
6583 .map(str::to_string)
6584 .or_else(|| relay_override.map(str::to_string))
6585 .unwrap_or_else(|| format!("https://{}", parsed.domain));
6586
6587 let mut trust = config::read_trust()?;
6589 crate::trust::add_agent_card_pin(&mut trust, &peer_card, Some("VERIFIED"));
6590 config::write_trust(&trust)?;
6591 let mut relay_state = config::read_relay_state()?;
6592 let existing_token = relay_state
6593 .get("peers")
6594 .and_then(|p| p.get(&peer_handle))
6595 .and_then(|p| p.get("slot_token"))
6596 .and_then(Value::as_str)
6597 .map(str::to_string)
6598 .unwrap_or_default();
6599 relay_state["peers"][&peer_handle] = json!({
6600 "relay_url": peer_relay,
6601 "slot_id": peer_slot_id,
6602 "slot_token": existing_token, });
6604 config::write_relay_state(&relay_state)?;
6605
6606 let our_card = config::read_agent_card()?;
6609 let sk_seed = config::read_private_key()?;
6610 let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
6611 let pk_b64 = our_card
6612 .get("verify_keys")
6613 .and_then(Value::as_object)
6614 .and_then(|m| m.values().next())
6615 .and_then(|v| v.get("key"))
6616 .and_then(Value::as_str)
6617 .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
6618 let pk_bytes = crate::signing::b64decode(pk_b64)?;
6619 let now = time::OffsetDateTime::now_utc()
6620 .format(&time::format_description::well_known::Rfc3339)
6621 .unwrap_or_default();
6622 let our_relay_state = config::read_relay_state().unwrap_or_else(|_| json!({}));
6627 let our_endpoints = crate::endpoints::self_endpoints(&our_relay_state);
6628 let mut body = json!({
6629 "card": our_card,
6630 "relay_url": our_relay,
6631 "slot_id": our_slot_id,
6632 "slot_token": our_slot_token,
6633 });
6634 if !our_endpoints.is_empty() {
6635 body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
6636 }
6637 let event = json!({
6638 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
6639 "timestamp": now,
6640 "from": our_did,
6641 "to": peer_did,
6642 "type": "pair_drop",
6643 "kind": 1100u32,
6644 "body": body,
6645 });
6646 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
6647
6648 let client = crate::relay_client::RelayClient::new(&peer_relay);
6650 let resp = client.handle_intro(&parsed.nick, &signed)?;
6651 let event_id = signed
6652 .get("event_id")
6653 .and_then(Value::as_str)
6654 .unwrap_or("")
6655 .to_string();
6656
6657 if as_json {
6658 println!(
6659 "{}",
6660 serde_json::to_string(&json!({
6661 "handle": handle_arg,
6662 "paired_with": peer_did,
6663 "peer_handle": peer_handle,
6664 "event_id": event_id,
6665 "drop_response": resp,
6666 "status": "drop_sent",
6667 }))?
6668 );
6669 } else {
6670 println!(
6671 "→ 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."
6672 );
6673 }
6674 Ok(())
6675}
6676
6677fn cmd_add_accept_pending(
6684 handle_arg: &str,
6685 peer_nick: &str,
6686 pending: &crate::pending_inbound_pair::PendingInboundPair,
6687 _our_relay: &str,
6688 _our_slot_id: &str,
6689 _our_slot_token: &str,
6690 as_json: bool,
6691) -> Result<()> {
6692 let mut trust = config::read_trust()?;
6695 crate::trust::add_agent_card_pin(&mut trust, &pending.peer_card, Some("VERIFIED"));
6696 config::write_trust(&trust)?;
6697
6698 let mut relay_state = config::read_relay_state()?;
6704 let endpoints_to_pin = if pending.peer_endpoints.is_empty() {
6705 vec![crate::endpoints::Endpoint::federation(
6706 pending.peer_relay_url.clone(),
6707 pending.peer_slot_id.clone(),
6708 pending.peer_slot_token.clone(),
6709 )]
6710 } else {
6711 pending.peer_endpoints.clone()
6712 };
6713 crate::endpoints::pin_peer_endpoints(
6714 &mut relay_state,
6715 &pending.peer_handle,
6716 &endpoints_to_pin,
6717 )?;
6718 config::write_relay_state(&relay_state)?;
6719
6720 crate::pair_invite::send_pair_drop_ack(
6722 &pending.peer_handle,
6723 &pending.peer_relay_url,
6724 &pending.peer_slot_id,
6725 &pending.peer_slot_token,
6726 )
6727 .with_context(|| {
6728 format!(
6729 "pair_drop_ack send to {} @ {} slot {} failed",
6730 pending.peer_handle, pending.peer_relay_url, pending.peer_slot_id
6731 )
6732 })?;
6733
6734 crate::pending_inbound_pair::consume_pending_inbound(peer_nick)?;
6736
6737 if as_json {
6738 println!(
6739 "{}",
6740 serde_json::to_string(&json!({
6741 "handle": handle_arg,
6742 "paired_with": pending.peer_did,
6743 "peer_handle": pending.peer_handle,
6744 "status": "bilateral_accepted",
6745 "via": "pending_inbound",
6746 }))?
6747 );
6748 } else {
6749 println!(
6750 "→ 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} \"...\"`.",
6751 peer = pending.peer_handle,
6752 );
6753 }
6754 Ok(())
6755}
6756
6757fn cmd_pair_accept(peer_nick: &str, as_json: bool) -> Result<()> {
6764 let nick = crate::agent_card::bare_handle(peer_nick);
6765 let pending = crate::pending_inbound_pair::read_pending_inbound(nick)?.ok_or_else(|| {
6766 anyhow!(
6767 "no pending pair request from {nick}. Run `wire pair-list-inbound` to see who is waiting, \
6768 or use `wire add <peer>@<relay>` to send a fresh outbound pair request."
6769 )
6770 })?;
6771 let (_our_did, our_relay, our_slot_id, our_slot_token) =
6772 crate::pair_invite::ensure_self_with_relay(None)?;
6773 let handle_arg = format!("{}@{}", pending.peer_handle, pending.peer_relay_url);
6774 cmd_add_accept_pending(
6775 &handle_arg,
6776 nick,
6777 &pending,
6778 &our_relay,
6779 &our_slot_id,
6780 &our_slot_token,
6781 as_json,
6782 )
6783}
6784
6785fn cmd_pair_list_inbound(as_json: bool) -> Result<()> {
6788 let items = crate::pending_inbound_pair::list_pending_inbound()?;
6789 if as_json {
6790 println!("{}", serde_json::to_string(&items)?);
6791 return Ok(());
6792 }
6793 if items.is_empty() {
6794 println!("no pending pair requests — your inbox is clear.");
6795 return Ok(());
6796 }
6797 let plural = if items.len() == 1 { "" } else { "s" };
6804 println!("{} pending pair request{plural}:\n", items.len());
6805 for p in &items {
6806 let ch = crate::character::Character::from_did(&p.peer_did);
6807 let glyph = crate::character::emoji_with_fallback(&ch);
6808 println!(
6811 " {glyph} {nick} ({handle}) wants to pair with you",
6812 nick = ch.nickname,
6813 handle = p.peer_handle,
6814 );
6815 }
6816 println!();
6817 println!(
6818 "→ to accept any: `wire accept <name>` (e.g. `wire accept {first}`)",
6819 first = items
6820 .first()
6821 .map(|p| {
6822 let ch = crate::character::Character::from_did(&p.peer_did);
6823 ch.nickname
6824 })
6825 .unwrap_or_else(|| "<name>".to_string())
6826 );
6827 println!("→ to refuse: `wire reject <name>`");
6828 Ok(())
6829}
6830
6831fn cmd_pair_reject(peer_nick: &str, as_json: bool) -> Result<()> {
6835 let nick = crate::agent_card::bare_handle(peer_nick);
6836 let existed = crate::pending_inbound_pair::read_pending_inbound(nick)?;
6837 crate::pending_inbound_pair::consume_pending_inbound(nick)?;
6838
6839 if as_json {
6840 println!(
6841 "{}",
6842 serde_json::to_string(&json!({
6843 "peer": nick,
6844 "rejected": existed.is_some(),
6845 "had_pending": existed.is_some(),
6846 }))?
6847 );
6848 } else if existed.is_some() {
6849 println!(
6850 "→ rejected pending pair from {nick}\n→ pending-inbound record deleted; no ack sent."
6851 );
6852 } else {
6853 println!("no pending pair from {nick} — nothing to reject");
6854 }
6855 Ok(())
6856}
6857
6858fn cmd_mesh(cmd: MeshCommand) -> Result<()> {
6869 match cmd {
6870 MeshCommand::Status { stale_secs, json } => cmd_session_mesh_status(stale_secs, json),
6871 MeshCommand::Broadcast {
6872 kind,
6873 scope,
6874 exclude,
6875 noreply,
6876 body,
6877 json,
6878 } => cmd_mesh_broadcast(&kind, &scope, &exclude, noreply, &body, json),
6879 MeshCommand::Role { action } => cmd_mesh_role(action),
6880 MeshCommand::Route {
6881 role,
6882 strategy,
6883 exclude,
6884 kind,
6885 body,
6886 json,
6887 } => cmd_mesh_route(&role, &strategy, &exclude, &kind, &body, json),
6888 }
6889}
6890
6891fn cmd_mesh_route(
6896 role: &str,
6897 strategy: &str,
6898 exclude: &[String],
6899 kind: &str,
6900 body_arg: &str,
6901 as_json: bool,
6902) -> Result<()> {
6903 use std::time::Instant;
6904
6905 if !config::is_initialized()? {
6906 bail!("not initialized — run `wire init <handle>` first");
6907 }
6908 let strategy = strategy.to_ascii_lowercase();
6909 if !matches!(strategy.as_str(), "round-robin" | "first" | "random") {
6910 bail!("unknown strategy `{strategy}` — use round-robin | first | random");
6911 }
6912
6913 let state = config::read_relay_state()?;
6916 let pinned: std::collections::BTreeSet<String> = state["peers"]
6917 .as_object()
6918 .map(|m| m.keys().cloned().collect())
6919 .unwrap_or_default();
6920
6921 let exclude_set: std::collections::HashSet<&str> = exclude.iter().map(String::as_str).collect();
6922
6923 let sessions = crate::session::list_sessions()?;
6928 let mut candidates: Vec<(String, Option<String>)> = Vec::new(); for s in &sessions {
6930 let handle = match s.handle.as_ref() {
6931 Some(h) => h.clone(),
6932 None => continue,
6933 };
6934 if exclude_set.contains(handle.as_str()) {
6935 continue;
6936 }
6937 if !pinned.contains(&handle) {
6938 continue;
6939 }
6940 let card_path = s
6941 .home_dir
6942 .join("config")
6943 .join("wire")
6944 .join("agent-card.json");
6945 let card_role = std::fs::read(&card_path)
6946 .ok()
6947 .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
6948 .and_then(|c| {
6949 c.get("profile")
6950 .and_then(|p| p.get("role"))
6951 .and_then(Value::as_str)
6952 .map(str::to_string)
6953 });
6954 if card_role.as_deref() == Some(role) {
6955 candidates.push((handle, s.did.clone()));
6956 }
6957 }
6958
6959 candidates.sort_by(|a, b| a.0.cmp(&b.0));
6960 candidates.dedup_by(|a, b| a.0 == b.0);
6961
6962 if candidates.is_empty() {
6963 bail!(
6964 "no pinned sister with role=`{role}` (run `wire mesh role list` to see what's available)"
6965 );
6966 }
6967
6968 let chosen = match strategy.as_str() {
6969 "first" => candidates[0].clone(),
6970 "random" => {
6971 use rand::Rng;
6972 let idx = rand::thread_rng().gen_range(0..candidates.len());
6973 candidates[idx].clone()
6974 }
6975 "round-robin" => {
6976 let cursor_path = mesh_route_cursor_path()?;
6981 let mut cursors: std::collections::BTreeMap<String, String> =
6982 read_mesh_route_cursors(&cursor_path);
6983 let last = cursors.get(role).cloned();
6984 let pick = match last {
6985 None => candidates[0].clone(),
6986 Some(last_h) => candidates
6987 .iter()
6988 .find(|(h, _)| h.as_str() > last_h.as_str())
6989 .cloned()
6990 .unwrap_or_else(|| candidates[0].clone()),
6991 };
6992 cursors.insert(role.to_string(), pick.0.clone());
6993 write_mesh_route_cursors(&cursor_path, &cursors)?;
6994 pick
6995 }
6996 _ => unreachable!(),
6997 };
6998
6999 let (chosen_handle, _chosen_did) = chosen;
7000
7001 let body_value: Value = if body_arg == "-" {
7003 use std::io::Read;
7004 let mut raw = String::new();
7005 std::io::stdin()
7006 .read_to_string(&mut raw)
7007 .with_context(|| "reading body from stdin")?;
7008 serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
7009 } else if let Some(path) = body_arg.strip_prefix('@') {
7010 let raw =
7011 std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
7012 serde_json::from_str(&raw).unwrap_or(Value::String(raw))
7013 } else {
7014 Value::String(body_arg.to_string())
7015 };
7016
7017 let sk_seed = config::read_private_key()?;
7018 let card = config::read_agent_card()?;
7019 let did = card
7020 .get("did")
7021 .and_then(Value::as_str)
7022 .ok_or_else(|| anyhow!("agent-card missing did"))?
7023 .to_string();
7024 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
7025 let pk_b64 = card
7026 .get("verify_keys")
7027 .and_then(Value::as_object)
7028 .and_then(|m| m.values().next())
7029 .and_then(|v| v.get("key"))
7030 .and_then(Value::as_str)
7031 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
7032 let pk_bytes = crate::signing::b64decode(pk_b64)?;
7033
7034 let kind_id = parse_kind(kind)?;
7035 let now_iso = time::OffsetDateTime::now_utc()
7036 .format(&time::format_description::well_known::Rfc3339)
7037 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
7038
7039 let event = json!({
7040 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
7041 "timestamp": now_iso,
7042 "from": did,
7043 "to": format!("did:wire:{chosen_handle}"),
7044 "type": kind,
7045 "kind": kind_id,
7046 "body": json!({
7047 "content": body_value,
7048 "routed_via": {
7049 "role": role,
7050 "strategy": strategy,
7051 },
7052 }),
7053 });
7054 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)
7055 .map_err(|e| anyhow!("sign_message_v31 failed: {e:?}"))?;
7056 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
7057
7058 let line = serde_json::to_vec(&signed)?;
7059 config::append_outbox_record(&chosen_handle, &line)?;
7060
7061 let endpoints = crate::endpoints::peer_endpoints_in_priority_order(&state, &chosen_handle);
7062 if endpoints.is_empty() {
7063 bail!(
7064 "no reachable endpoint pinned for `{chosen_handle}` (the role matched, but we can't push)"
7065 );
7066 }
7067 let start = Instant::now();
7068 let mut delivered = false;
7069 let mut last_err: Option<String> = None;
7070 let mut via_scope: Option<String> = None;
7071 for ep in &endpoints {
7072 match crate::relay_client::post_event_to_endpoint(ep, &signed) {
7077 Ok(_) => {
7078 delivered = true;
7079 via_scope = Some(
7080 match ep.scope {
7081 crate::endpoints::EndpointScope::Local => "local",
7082 crate::endpoints::EndpointScope::Lan => "lan",
7083 crate::endpoints::EndpointScope::Uds => "uds",
7084 crate::endpoints::EndpointScope::Federation => "federation",
7085 }
7086 .to_string(),
7087 );
7088 break;
7089 }
7090 Err(e) => last_err = Some(format!("{e:#}")),
7091 }
7092 }
7093 let rtt_ms = start.elapsed().as_millis() as u64;
7094
7095 let summary = json!({
7096 "role": role,
7097 "strategy": strategy,
7098 "routed_to": chosen_handle,
7099 "event_id": event_id,
7100 "delivered": delivered,
7101 "delivered_via": via_scope,
7102 "rtt_ms": rtt_ms,
7103 "candidates": candidates.iter().map(|(h, _)| h.clone()).collect::<Vec<_>>(),
7104 "error": last_err,
7105 });
7106
7107 if as_json {
7108 println!("{}", serde_json::to_string(&summary)?);
7109 } else if delivered {
7110 let via = via_scope.as_deref().unwrap_or("?");
7111 println!("wire mesh route: {role} → {chosen_handle} ({rtt_ms}ms, {via})");
7112 } else {
7113 let err = last_err.as_deref().unwrap_or("no endpoints reachable");
7114 bail!("delivery to `{chosen_handle}` failed: {err}");
7115 }
7116 Ok(())
7117}
7118
7119fn mesh_route_cursor_path() -> Result<std::path::PathBuf> {
7120 Ok(config::state_dir()?.join("mesh-route-cursor.json"))
7121}
7122
7123fn read_mesh_route_cursors(path: &std::path::Path) -> std::collections::BTreeMap<String, String> {
7124 std::fs::read(path)
7125 .ok()
7126 .and_then(|b| serde_json::from_slice(&b).ok())
7127 .unwrap_or_default()
7128}
7129
7130fn write_mesh_route_cursors(
7131 path: &std::path::Path,
7132 cursors: &std::collections::BTreeMap<String, String>,
7133) -> Result<()> {
7134 if let Some(parent) = path.parent() {
7135 std::fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
7136 }
7137 let body = serde_json::to_vec_pretty(cursors)?;
7138 std::fs::write(path, body).with_context(|| format!("writing {path:?}"))?;
7139 Ok(())
7140}
7141
7142fn cmd_mesh_role(action: MeshRoleAction) -> Result<()> {
7147 match action {
7148 MeshRoleAction::Set { role, json } => {
7149 validate_role_tag(&role)?;
7150 let new_profile =
7151 crate::pair_profile::write_profile_field("role", Value::String(role.clone()))?;
7152 if json {
7153 println!(
7154 "{}",
7155 serde_json::to_string(&json!({
7156 "role": role,
7157 "profile": new_profile,
7158 }))?
7159 );
7160 } else {
7161 println!("self role = {role} (signed into agent-card)");
7162 }
7163 }
7164 MeshRoleAction::Get { peer, json } => {
7165 let (who, role) = match peer.as_deref() {
7166 None => {
7167 let card = config::read_agent_card()?;
7168 let role = card
7169 .get("profile")
7170 .and_then(|p| p.get("role"))
7171 .and_then(Value::as_str)
7172 .map(str::to_string);
7173 let who = card
7174 .get("did")
7175 .and_then(Value::as_str)
7176 .map(|d| crate::agent_card::display_handle_from_did(d).to_string())
7177 .unwrap_or_else(|| "self".to_string());
7178 (who, role)
7179 }
7180 Some(handle) => {
7181 let bare = crate::agent_card::bare_handle(handle).to_string();
7182 let trust = config::read_trust()?;
7183 let role = trust
7184 .get("agents")
7185 .and_then(|a| a.get(&bare))
7186 .and_then(|a| a.get("card"))
7187 .and_then(|c| c.get("profile"))
7188 .and_then(|p| p.get("role"))
7189 .and_then(Value::as_str)
7190 .map(str::to_string);
7191 (bare, role)
7192 }
7193 };
7194 if json {
7195 println!(
7196 "{}",
7197 serde_json::to_string(&json!({
7198 "handle": who,
7199 "role": role,
7200 }))?
7201 );
7202 } else {
7203 match role {
7204 Some(r) => println!("{who}: {r}"),
7205 None => println!("{who}: (unset)"),
7206 }
7207 }
7208 }
7209 MeshRoleAction::List { json } => {
7210 let mut self_did: Option<String> = None;
7211 if let Ok(card) = config::read_agent_card() {
7212 self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
7213 }
7214 let sessions = crate::session::list_sessions()?;
7215 let mut rows: Vec<Value> = Vec::new();
7216 for s in &sessions {
7217 let card_path = s
7218 .home_dir
7219 .join("config")
7220 .join("wire")
7221 .join("agent-card.json");
7222 let role = std::fs::read(&card_path)
7223 .ok()
7224 .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
7225 .and_then(|c| {
7226 c.get("profile")
7227 .and_then(|p| p.get("role"))
7228 .and_then(Value::as_str)
7229 .map(str::to_string)
7230 });
7231 let is_self = match (&self_did, &s.did) {
7232 (Some(a), Some(b)) => a == b,
7233 _ => false,
7234 };
7235 rows.push(json!({
7236 "name": s.name,
7237 "handle": s.handle,
7238 "role": role,
7239 "self": is_self,
7240 }));
7241 }
7242 rows.sort_by(|a, b| {
7243 a["name"]
7244 .as_str()
7245 .unwrap_or("")
7246 .cmp(b["name"].as_str().unwrap_or(""))
7247 });
7248 if json {
7249 println!("{}", serde_json::to_string(&json!({"sessions": rows}))?);
7250 } else if rows.is_empty() {
7251 println!("no sister sessions on this machine.");
7252 } else {
7253 println!("SISTER ROLES (this machine):");
7254 for r in &rows {
7255 let name = r["name"].as_str().unwrap_or("?");
7256 let role = r["role"].as_str().unwrap_or("(unset)");
7257 let marker = if r["self"].as_bool().unwrap_or(false) {
7258 " ← you"
7259 } else {
7260 ""
7261 };
7262 println!(" {name:<24} {role}{marker}");
7263 }
7264 }
7265 }
7266 MeshRoleAction::Clear { json } => {
7267 let new_profile = crate::pair_profile::write_profile_field("role", Value::Null)?;
7268 if json {
7269 println!(
7270 "{}",
7271 serde_json::to_string(&json!({
7272 "cleared": true,
7273 "profile": new_profile,
7274 }))?
7275 );
7276 } else {
7277 println!("self role cleared");
7278 }
7279 }
7280 }
7281 Ok(())
7282}
7283
7284fn validate_role_tag(role: &str) -> Result<()> {
7289 if role.is_empty() {
7290 bail!("role must not be empty (use `wire mesh role --clear` to unset)");
7291 }
7292 if role.len() > 32 {
7293 bail!("role too long ({} chars; max 32)", role.len());
7294 }
7295 for c in role.chars() {
7296 if !(c.is_ascii_alphanumeric() || c == '-' || c == '_') {
7297 bail!("role contains illegal char {c:?} (allowed: A-Z a-z 0-9 - _)");
7298 }
7299 }
7300 Ok(())
7301}
7302
7303fn cmd_mesh_broadcast(
7323 kind: &str,
7324 scope_str: &str,
7325 exclude: &[String],
7326 _noreply: bool,
7327 body_arg: &str,
7328 as_json: bool,
7329) -> Result<()> {
7330 use std::time::Instant;
7331
7332 if !config::is_initialized()? {
7333 bail!("not initialized — run `wire init <handle>` first");
7334 }
7335
7336 let scope = match scope_str {
7337 "local" => crate::endpoints::EndpointScope::Local,
7338 "federation" => crate::endpoints::EndpointScope::Federation,
7339 "both" => {
7340 crate::endpoints::EndpointScope::Local
7344 }
7345 other => bail!("unknown scope `{other}` — use local | federation | both"),
7346 };
7347 let any_scope = scope_str == "both";
7348
7349 let state = config::read_relay_state()?;
7350 let peers = state["peers"].as_object().cloned().unwrap_or_default();
7351 if peers.is_empty() {
7352 bail!("no peers pinned — run `wire accept <invite-url>` or `wire pair-accept` first");
7353 }
7354
7355 let exclude_set: std::collections::HashSet<&str> = exclude.iter().map(String::as_str).collect();
7356
7357 struct Target {
7361 handle: String,
7362 endpoints: Vec<crate::endpoints::Endpoint>,
7363 }
7364 let mut targets: Vec<Target> = Vec::new();
7365 let mut skipped_wrong_scope: Vec<String> = Vec::new();
7366 let mut skipped_excluded: Vec<String> = Vec::new();
7367 for handle in peers.keys() {
7368 if exclude_set.contains(handle.as_str()) {
7369 skipped_excluded.push(handle.clone());
7370 continue;
7371 }
7372 let ordered = crate::endpoints::peer_endpoints_in_priority_order(&state, handle);
7373 let filtered: Vec<crate::endpoints::Endpoint> = ordered
7374 .into_iter()
7375 .filter(|ep| any_scope || ep.scope == scope)
7376 .collect();
7377 if filtered.is_empty() {
7378 skipped_wrong_scope.push(handle.clone());
7379 continue;
7380 }
7381 targets.push(Target {
7382 handle: handle.clone(),
7383 endpoints: filtered,
7384 });
7385 }
7386
7387 if targets.is_empty() {
7388 bail!(
7389 "no peers matched scope=`{scope_str}` after exclude filter ({} excluded, {} wrong-scope)",
7390 skipped_excluded.len(),
7391 skipped_wrong_scope.len()
7392 );
7393 }
7394
7395 let sk_seed = config::read_private_key()?;
7397 let card = config::read_agent_card()?;
7398 let did = card
7399 .get("did")
7400 .and_then(Value::as_str)
7401 .ok_or_else(|| anyhow!("agent-card missing did"))?
7402 .to_string();
7403 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
7404 let pk_b64 = card
7405 .get("verify_keys")
7406 .and_then(Value::as_object)
7407 .and_then(|m| m.values().next())
7408 .and_then(|v| v.get("key"))
7409 .and_then(Value::as_str)
7410 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
7411 let pk_bytes = crate::signing::b64decode(pk_b64)?;
7412
7413 let body_value: Value = if body_arg == "-" {
7414 use std::io::Read;
7415 let mut raw = String::new();
7416 std::io::stdin()
7417 .read_to_string(&mut raw)
7418 .with_context(|| "reading body from stdin")?;
7419 serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
7420 } else if let Some(path) = body_arg.strip_prefix('@') {
7421 let raw =
7422 std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
7423 serde_json::from_str(&raw).unwrap_or(Value::String(raw))
7424 } else {
7425 Value::String(body_arg.to_string())
7426 };
7427
7428 let kind_id = parse_kind(kind)?;
7429 let now_iso = time::OffsetDateTime::now_utc()
7430 .format(&time::format_description::well_known::Rfc3339)
7431 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
7432
7433 let broadcast_id = generate_broadcast_id();
7434 let target_count = targets.len();
7435
7436 let mut signed_per_peer: Vec<(String, Vec<crate::endpoints::Endpoint>, Value, String)> =
7440 Vec::with_capacity(targets.len());
7441 for t in &targets {
7442 let body = json!({
7443 "content": body_value,
7444 "broadcast_id": broadcast_id,
7445 "broadcast_target_count": target_count,
7446 });
7447 let event = json!({
7448 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
7449 "timestamp": now_iso,
7450 "from": did,
7451 "to": format!("did:wire:{}", t.handle),
7452 "type": kind,
7453 "kind": kind_id,
7454 "body": body,
7455 });
7456 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)
7457 .map_err(|e| anyhow!("sign_message_v31 failed for `{}`: {e:?}", t.handle))?;
7458 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
7459 signed_per_peer.push((t.handle.clone(), t.endpoints.clone(), signed, event_id));
7460 }
7461
7462 for (peer, _, signed, _) in &signed_per_peer {
7466 let line = serde_json::to_vec(signed)?;
7467 config::append_outbox_record(peer, &line)?;
7468 }
7469
7470 use std::sync::mpsc;
7474 let (tx, rx) = mpsc::channel::<Value>();
7475 std::thread::scope(|s| {
7476 for (peer, endpoints, signed, event_id) in &signed_per_peer {
7477 let tx = tx.clone();
7478 let peer = peer.clone();
7479 let event_id = event_id.clone();
7480 let endpoints = endpoints.clone();
7481 let signed = signed.clone();
7482 s.spawn(move || {
7483 let start = Instant::now();
7484 let mut delivered = false;
7485 let mut last_err: Option<String> = None;
7486 let mut delivered_via: Option<String> = None;
7487 for ep in &endpoints {
7488 match crate::relay_client::post_event_to_endpoint(ep, &signed) {
7493 Ok(_) => {
7494 delivered = true;
7495 delivered_via = Some(
7496 match ep.scope {
7497 crate::endpoints::EndpointScope::Local => "local",
7498 crate::endpoints::EndpointScope::Lan => "lan",
7499 crate::endpoints::EndpointScope::Uds => "uds",
7500 crate::endpoints::EndpointScope::Federation => "federation",
7501 }
7502 .to_string(),
7503 );
7504 break;
7505 }
7506 Err(e) => last_err = Some(format!("{e:#}")),
7507 }
7508 }
7509 let rtt_ms = start.elapsed().as_millis() as u64;
7510 let _ = tx.send(json!({
7511 "peer": peer,
7512 "event_id": event_id,
7513 "delivered": delivered,
7514 "delivered_via": delivered_via,
7515 "rtt_ms": rtt_ms,
7516 "error": last_err,
7517 }));
7518 });
7519 }
7520 });
7521 drop(tx);
7522
7523 let mut results: Vec<Value> = rx.iter().collect();
7524 results.sort_by(|a, b| {
7525 a["peer"]
7526 .as_str()
7527 .unwrap_or("")
7528 .cmp(b["peer"].as_str().unwrap_or(""))
7529 });
7530
7531 let delivered = results
7532 .iter()
7533 .filter(|r| r["delivered"].as_bool().unwrap_or(false))
7534 .count();
7535 let failed = results.len() - delivered;
7536
7537 let summary = json!({
7538 "broadcast_id": broadcast_id,
7539 "kind": kind,
7540 "scope": scope_str,
7541 "target_count": target_count,
7542 "delivered": delivered,
7543 "failed": failed,
7544 "skipped_excluded": skipped_excluded,
7545 "skipped_wrong_scope": skipped_wrong_scope,
7546 "results": results,
7547 });
7548
7549 if as_json {
7550 println!("{}", serde_json::to_string(&summary)?);
7551 return Ok(());
7552 }
7553
7554 println!("wire mesh broadcast: scope={scope_str} → {target_count} pinned peer(s)");
7555 for r in &results {
7556 let peer = r["peer"].as_str().unwrap_or("?");
7557 let delivered = r["delivered"].as_bool().unwrap_or(false);
7558 let rtt = r["rtt_ms"].as_u64().unwrap_or(0);
7559 let via = r["delivered_via"].as_str().unwrap_or("");
7560 if delivered {
7561 println!(" {peer:<24} ✓ delivered ({rtt}ms, {via})");
7562 } else {
7563 let err = r["error"].as_str().unwrap_or("?");
7564 println!(" {peer:<24} ✗ failed — {err}");
7565 }
7566 }
7567 if !skipped_excluded.is_empty() {
7568 println!(" excluded: {}", skipped_excluded.join(", "));
7569 }
7570 if !skipped_wrong_scope.is_empty() {
7571 println!(
7572 " skipped (wrong scope): {}",
7573 skipped_wrong_scope.join(", ")
7574 );
7575 }
7576 println!("broadcast_id: {broadcast_id}");
7577 Ok(())
7578}
7579
7580fn generate_broadcast_id() -> String {
7584 use rand::RngCore;
7585 let mut buf = [0u8; 16];
7586 rand::thread_rng().fill_bytes(&mut buf);
7587 let h = hex::encode(buf);
7588 format!(
7589 "{}-{}-{}-{}-{}",
7590 &h[0..8],
7591 &h[8..12],
7592 &h[12..16],
7593 &h[16..20],
7594 &h[20..32],
7595 )
7596}
7597
7598fn cmd_session(cmd: SessionCommand) -> Result<()> {
7599 match cmd {
7600 SessionCommand::New {
7601 name,
7602 relay,
7603 with_local,
7604 local_relay,
7605 with_lan,
7606 lan_relay,
7607 with_uds,
7608 uds_socket,
7609 no_daemon,
7610 local_only,
7611 json,
7612 } => cmd_session_new(
7613 name.as_deref(),
7614 &relay,
7615 with_local,
7616 &local_relay,
7617 with_lan,
7618 lan_relay.as_deref(),
7619 with_uds,
7620 uds_socket.as_deref(),
7621 no_daemon,
7622 local_only,
7623 json,
7624 ),
7625 SessionCommand::List { json } => cmd_session_list(json),
7626 SessionCommand::ListLocal { json } => cmd_session_list_local(json),
7627 SessionCommand::PairAllLocal {
7628 settle_secs,
7629 federation_relay,
7630 json,
7631 } => cmd_session_pair_all_local(settle_secs, &federation_relay, json),
7632 SessionCommand::MeshStatus { stale_secs, json } => {
7633 cmd_session_mesh_status(stale_secs, json)
7634 }
7635 SessionCommand::Env { name, json } => cmd_session_env(name.as_deref(), json),
7636 SessionCommand::Current { json } => cmd_session_current(json),
7637 SessionCommand::Bind { name, json } => cmd_session_bind(name.as_deref(), json),
7638 SessionCommand::Destroy { name, force, json } => cmd_session_destroy(&name, force, json),
7639 }
7640}
7641
7642fn cmd_session_bind(name_arg: Option<&str>, json: bool) -> Result<()> {
7643 let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
7644 let cwd_str = cwd.to_string_lossy().into_owned();
7645
7646 let resolved_name = match name_arg {
7647 Some(n) => crate::session::sanitize_name(n),
7648 None => crate::session::sanitize_name(
7649 cwd.file_name()
7650 .and_then(|s| s.to_str())
7651 .ok_or_else(|| anyhow!("cwd has no basename to derive session name from"))?,
7652 ),
7653 };
7654
7655 let session_home = crate::session::session_dir(&resolved_name)?;
7656 if !session_home.exists() {
7657 bail!(
7658 "session `{resolved_name}` does not exist (looked at {}). Create it first with `wire session new {resolved_name}` or pass an existing name.",
7659 session_home.display()
7660 );
7661 }
7662
7663 let prior = crate::session::read_registry()
7664 .ok()
7665 .and_then(|r| r.by_cwd.get(&cwd_str).cloned());
7666 if prior.as_deref() == Some(resolved_name.as_str()) {
7667 if json {
7668 println!(
7669 "{}",
7670 serde_json::to_string(&json!({
7671 "cwd": cwd_str,
7672 "session": resolved_name,
7673 "changed": false,
7674 }))?
7675 );
7676 } else {
7677 println!("cwd `{cwd_str}` already bound to session `{resolved_name}` (no change)");
7678 }
7679 return Ok(());
7680 }
7681 if let Some(prior_name) = &prior {
7682 eprintln!(
7683 "wire session bind: cwd `{cwd_str}` was bound to `{prior_name}`; overwriting with `{resolved_name}`."
7684 );
7685 }
7686
7687 crate::session::update_registry(|reg| {
7688 reg.by_cwd.insert(cwd_str.clone(), resolved_name.clone());
7689 Ok(())
7690 })?;
7691
7692 if json {
7693 println!(
7694 "{}",
7695 serde_json::to_string(&json!({
7696 "cwd": cwd_str,
7697 "session": resolved_name,
7698 "changed": true,
7699 "previous": prior,
7700 }))?
7701 );
7702 } else {
7703 println!("bound cwd `{cwd_str}` → session `{resolved_name}`");
7704 println!("(next `wire` invocation from this cwd will auto-detect into this session)");
7705 }
7706 Ok(())
7707}
7708
7709fn resolve_session_name(name: Option<&str>) -> Result<String> {
7710 if let Some(n) = name {
7711 return Ok(crate::session::sanitize_name(n));
7712 }
7713 let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
7714 let registry = crate::session::read_registry().unwrap_or_default();
7715 Ok(crate::session::derive_name_from_cwd(&cwd, ®istry))
7716}
7717
7718#[allow(clippy::too_many_arguments)] fn cmd_session_new(
7722 name_arg: Option<&str>,
7723 relay: &str,
7724 with_local: bool,
7725 local_relay: &str,
7726 with_lan: bool,
7727 lan_relay: Option<&str>,
7728 with_uds: bool,
7729 uds_socket: Option<&std::path::Path>,
7730 no_daemon: bool,
7731 local_only: bool,
7732 as_json: bool,
7733) -> Result<()> {
7734 let with_local = with_local || local_only;
7737 if with_lan && lan_relay.is_none() {
7739 bail!("--with-lan requires --lan-relay <url> (e.g. http://192.168.1.50:8771)");
7740 }
7741 if with_uds && uds_socket.is_none() {
7743 bail!("--with-uds requires --uds-socket <path> (e.g. /tmp/wire.sock)");
7744 }
7745 let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
7746 let mut registry = crate::session::read_registry().unwrap_or_default();
7747 let name = match name_arg {
7748 Some(n) => crate::session::sanitize_name(n),
7749 None => crate::session::derive_name_from_cwd(&cwd, ®istry),
7750 };
7751 let session_home = crate::session::session_dir(&name)?;
7752
7753 let already_exists = session_home.exists()
7754 && session_home
7755 .join("config")
7756 .join("wire")
7757 .join("agent-card.json")
7758 .exists();
7759 if already_exists {
7760 registry
7764 .by_cwd
7765 .insert(cwd.to_string_lossy().into_owned(), name.clone());
7766 crate::session::write_registry(®istry)?;
7767 let info = render_session_info(&name, &session_home, &cwd)?;
7768 emit_session_new_result(&info, "already_exists", as_json)?;
7769 if !no_daemon {
7770 ensure_session_daemon(&session_home)?;
7771 }
7772 return Ok(());
7773 }
7774
7775 std::fs::create_dir_all(&session_home)
7776 .with_context(|| format!("creating session dir {session_home:?}"))?;
7777
7778 let init_args: Vec<&str> = if local_only {
7787 vec!["init", &name, "--offline"]
7788 } else {
7789 vec!["init", &name, "--relay", relay]
7790 };
7791 let init_status = run_wire_with_home(&session_home, &init_args)?;
7792 if !init_status.success() {
7793 let how = if local_only {
7794 format!("`wire init {name}` (local-only)")
7795 } else {
7796 format!("`wire init {name} --relay {relay}`")
7797 };
7798 bail!("{how} failed inside session dir {session_home:?}");
7799 }
7800
7801 let effective_handle = if local_only {
7806 name.clone()
7807 } else {
7808 let mut claim_attempt = 0u32;
7809 let mut effective = name.clone();
7810 loop {
7811 claim_attempt += 1;
7812 let status =
7813 run_wire_with_home(&session_home, &["claim", &effective, "--relay", relay])?;
7814 if status.success() {
7815 break;
7816 }
7817 if claim_attempt >= 5 {
7818 bail!(
7819 "5 failed attempts to claim a handle on {relay} for session {name}. \
7820 Try `wire session destroy {name} --force` and re-run with a different name, \
7821 or use `--local-only` if you don't need a federation address."
7822 );
7823 }
7824 let attempt_path = cwd.join(format!("__attempt_{claim_attempt}"));
7825 let suffix = crate::session::derive_name_from_cwd(&attempt_path, ®istry);
7826 let token = suffix
7827 .rsplit('-')
7828 .next()
7829 .filter(|t| t.len() == 4)
7830 .map(str::to_string)
7831 .unwrap_or_else(|| format!("{claim_attempt}"));
7832 effective = format!("{name}-{token}");
7833 }
7834 effective
7835 };
7836
7837 registry
7840 .by_cwd
7841 .insert(cwd.to_string_lossy().into_owned(), name.clone());
7842 crate::session::write_registry(®istry)?;
7843
7844 if with_local {
7855 try_allocate_local_slot(&session_home, &effective_handle, relay, local_relay);
7856 if local_only {
7857 let relay_state_path = session_home.join("config").join("wire").join("relay.json");
7862 let state: Value = std::fs::read(&relay_state_path)
7863 .ok()
7864 .and_then(|b| serde_json::from_slice(&b).ok())
7865 .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
7866 let endpoints = crate::endpoints::self_endpoints(&state);
7867 let has_local = endpoints
7868 .iter()
7869 .any(|e| e.scope == crate::endpoints::EndpointScope::Local);
7870 if !has_local {
7871 bail!(
7872 "--local-only requested but local-relay probe at {local_relay} failed — \
7873 ensure the local relay is running (`wire service install --local-relay`), \
7874 then re-run `wire session new {name} --local-only`."
7875 );
7876 }
7877 }
7878 }
7879
7880 if with_lan && let Some(lan_url) = lan_relay {
7884 try_allocate_lan_slot(&session_home, &effective_handle, lan_url);
7885 }
7886 if with_uds && let Some(socket_path) = uds_socket {
7888 try_allocate_uds_slot(&session_home, &effective_handle, socket_path);
7889 }
7890
7891 if !no_daemon {
7892 ensure_session_daemon(&session_home)?;
7893 }
7894
7895 let info = render_session_info(&name, &session_home, &cwd)?;
7896 emit_session_new_result(&info, "created", as_json)
7897}
7898
7899#[cfg(unix)]
7909fn try_allocate_uds_slot(
7910 session_home: &std::path::Path,
7911 handle: &str,
7912 uds_socket: &std::path::Path,
7913) {
7914 let healthz = match crate::relay_client::uds_request(uds_socket, "GET", "/healthz", &[], b"") {
7917 Ok((200, _)) => true,
7918 Ok((status, body)) => {
7919 eprintln!(
7920 "wire session new: UDS relay probe at {uds_socket:?} returned {status} ({}) — not publishing UDS endpoint",
7921 String::from_utf8_lossy(&body)
7922 );
7923 return;
7924 }
7925 Err(e) => {
7926 eprintln!(
7927 "wire session new: UDS relay at {uds_socket:?} unreachable ({e:#}) — \
7928 not publishing UDS endpoint. Start one with `wire relay-server --uds <path>`."
7929 );
7930 return;
7931 }
7932 };
7933 if !healthz {
7934 return;
7935 }
7936
7937 let alloc_body = serde_json::json!({"handle": handle}).to_string();
7939 let (status, body) = match crate::relay_client::uds_request(
7940 uds_socket,
7941 "POST",
7942 "/v1/slot/allocate",
7943 &[("Content-Type", "application/json")],
7944 alloc_body.as_bytes(),
7945 ) {
7946 Ok(r) => r,
7947 Err(e) => {
7948 eprintln!(
7949 "wire session new: UDS relay slot allocation request failed: {e:#} — not publishing UDS endpoint"
7950 );
7951 return;
7952 }
7953 };
7954 if status >= 300 {
7955 eprintln!(
7956 "wire session new: UDS relay slot allocation returned {status} ({}) — not publishing UDS endpoint",
7957 String::from_utf8_lossy(&body)
7958 );
7959 return;
7960 }
7961 let alloc: crate::relay_client::AllocateResponse = match serde_json::from_slice(&body) {
7962 Ok(a) => a,
7963 Err(e) => {
7964 eprintln!("wire session new: UDS relay returned unparseable allocate response: {e:#}");
7965 return;
7966 }
7967 };
7968
7969 let state_path = session_home.join("config").join("wire").join("relay.json");
7970 let mut state: serde_json::Value = std::fs::read(&state_path)
7971 .ok()
7972 .and_then(|b| serde_json::from_slice(&b).ok())
7973 .unwrap_or_else(|| serde_json::json!({}));
7974
7975 let mut endpoints: Vec<crate::endpoints::Endpoint> = state
7976 .get("self")
7977 .and_then(|s| s.get("endpoints"))
7978 .and_then(|e| e.as_array())
7979 .map(|arr| {
7980 arr.iter()
7981 .filter_map(|v| {
7982 serde_json::from_value::<crate::endpoints::Endpoint>(v.clone()).ok()
7983 })
7984 .collect()
7985 })
7986 .unwrap_or_default();
7987 endpoints.push(crate::endpoints::Endpoint::uds(
7988 format!("unix://{}", uds_socket.display()),
7989 alloc.slot_id.clone(),
7990 alloc.slot_token.clone(),
7991 ));
7992
7993 let self_obj = state
7994 .as_object_mut()
7995 .expect("relay_state root is an object")
7996 .entry("self")
7997 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
7998 if !self_obj.is_object() {
7999 *self_obj = serde_json::Value::Object(serde_json::Map::new());
8000 }
8001 if let Some(obj) = self_obj.as_object_mut() {
8002 obj.insert(
8003 "endpoints".into(),
8004 serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
8005 );
8006 }
8007 if let Err(e) = std::fs::write(
8008 &state_path,
8009 serde_json::to_vec_pretty(&state).expect("relay_state serializable"),
8010 ) {
8011 eprintln!("wire session new: failed to write {state_path:?}: {e}");
8012 return;
8013 }
8014 eprintln!(
8015 "wire session new: UDS slot allocated on unix://{} (slot_id={}) — sister sessions will see this endpoint in your agent-card",
8016 uds_socket.display(),
8017 alloc.slot_id
8018 );
8019}
8020
8021#[cfg(not(unix))]
8022fn try_allocate_uds_slot(
8023 _session_home: &std::path::Path,
8024 _handle: &str,
8025 _uds_socket: &std::path::Path,
8026) {
8027 eprintln!(
8028 "wire session new: --with-uds is Unix-only (Windows lacks AF_UNIX in tokio/reqwest); ignoring"
8029 );
8030}
8031
8032fn try_allocate_lan_slot(session_home: &std::path::Path, handle: &str, lan_relay: &str) {
8042 let probe = match crate::relay_client::build_blocking_client(Some(
8043 std::time::Duration::from_millis(500),
8044 )) {
8045 Ok(c) => c,
8046 Err(e) => {
8047 eprintln!("wire session new: cannot build LAN probe client for {lan_relay}: {e:#}");
8048 return;
8049 }
8050 };
8051 let healthz_url = format!("{}/healthz", lan_relay.trim_end_matches('/'));
8052 match probe.get(&healthz_url).send() {
8053 Ok(resp) if resp.status().is_success() => {}
8054 Ok(resp) => {
8055 eprintln!(
8056 "wire session new: LAN relay probe at {healthz_url} returned {} — not publishing LAN endpoint",
8057 resp.status()
8058 );
8059 return;
8060 }
8061 Err(e) => {
8062 eprintln!(
8063 "wire session new: LAN relay at {lan_relay} unreachable ({}) — not publishing LAN endpoint. \
8064 Start one on the LAN-bound interface with `wire relay-server --bind <LAN-IP>:8771 --local-only`.",
8065 crate::relay_client::format_transport_error(&anyhow::Error::new(e))
8066 );
8067 return;
8068 }
8069 };
8070
8071 let lan_client = crate::relay_client::RelayClient::new(lan_relay);
8072 let alloc = match lan_client.allocate_slot(Some(handle)) {
8073 Ok(a) => a,
8074 Err(e) => {
8075 eprintln!(
8076 "wire session new: LAN relay slot allocation failed: {e:#} — not publishing LAN endpoint"
8077 );
8078 return;
8079 }
8080 };
8081
8082 let state_path = session_home.join("config").join("wire").join("relay.json");
8083 let mut state: serde_json::Value = std::fs::read(&state_path)
8084 .ok()
8085 .and_then(|b| serde_json::from_slice(&b).ok())
8086 .unwrap_or_else(|| serde_json::json!({}));
8087
8088 let mut endpoints: Vec<crate::endpoints::Endpoint> = state
8091 .get("self")
8092 .and_then(|s| s.get("endpoints"))
8093 .and_then(|e| e.as_array())
8094 .map(|arr| {
8095 arr.iter()
8096 .filter_map(|v| {
8097 serde_json::from_value::<crate::endpoints::Endpoint>(v.clone()).ok()
8098 })
8099 .collect()
8100 })
8101 .unwrap_or_default();
8102 endpoints.push(crate::endpoints::Endpoint::lan(
8103 lan_relay.trim_end_matches('/').to_string(),
8104 alloc.slot_id.clone(),
8105 alloc.slot_token.clone(),
8106 ));
8107
8108 let self_obj = state
8109 .as_object_mut()
8110 .expect("relay_state root is an object")
8111 .entry("self")
8112 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
8113 if !self_obj.is_object() {
8114 *self_obj = serde_json::Value::Object(serde_json::Map::new());
8115 }
8116 if let Some(obj) = self_obj.as_object_mut() {
8117 obj.insert(
8118 "endpoints".into(),
8119 serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
8120 );
8121 }
8122 if let Err(e) = std::fs::write(
8123 &state_path,
8124 serde_json::to_vec_pretty(&state).expect("relay_state serializable"),
8125 ) {
8126 eprintln!("wire session new: failed to write {state_path:?}: {e}");
8127 return;
8128 }
8129 eprintln!(
8130 "wire session new: LAN slot allocated on {lan_relay} (slot_id={}) — peers will see this endpoint in your agent-card",
8131 alloc.slot_id
8132 );
8133}
8134
8135fn try_allocate_local_slot(
8143 session_home: &std::path::Path,
8144 handle: &str,
8145 _federation_relay: &str,
8146 local_relay: &str,
8147) {
8148 let probe = match crate::relay_client::build_blocking_client(Some(
8151 std::time::Duration::from_millis(500),
8152 )) {
8153 Ok(c) => c,
8154 Err(e) => {
8155 eprintln!("wire session new: cannot build probe client for {local_relay}: {e:#}");
8156 return;
8157 }
8158 };
8159 let healthz_url = format!("{}/healthz", local_relay.trim_end_matches('/'));
8160 match probe.get(&healthz_url).send() {
8161 Ok(resp) if resp.status().is_success() => {}
8162 Ok(resp) => {
8163 eprintln!(
8164 "wire session new: local relay probe at {healthz_url} returned {} — staying federation-only",
8165 resp.status()
8166 );
8167 return;
8168 }
8169 Err(e) => {
8170 eprintln!(
8171 "wire session new: local relay at {local_relay} unreachable ({}) — staying federation-only. \
8172 Start one with `wire relay-server --bind 127.0.0.1:8771 --local-only`.",
8173 crate::relay_client::format_transport_error(&anyhow::Error::new(e))
8174 );
8175 return;
8176 }
8177 };
8178
8179 let local_client = crate::relay_client::RelayClient::new(local_relay);
8181 let alloc = match local_client.allocate_slot(Some(handle)) {
8182 Ok(a) => a,
8183 Err(e) => {
8184 eprintln!(
8185 "wire session new: local relay slot allocation failed: {e:#} — staying federation-only"
8186 );
8187 return;
8188 }
8189 };
8190
8191 let state_path = session_home.join("config").join("wire").join("relay.json");
8206 let mut state: serde_json::Value = std::fs::read(&state_path)
8207 .ok()
8208 .and_then(|b| serde_json::from_slice(&b).ok())
8209 .unwrap_or_else(|| serde_json::json!({}));
8210 let fed_endpoint = state.get("self").and_then(|s| {
8213 let url = s.get("relay_url").and_then(serde_json::Value::as_str)?;
8214 let slot_id = s.get("slot_id").and_then(serde_json::Value::as_str)?;
8215 let slot_token = s.get("slot_token").and_then(serde_json::Value::as_str)?;
8216 Some(crate::endpoints::Endpoint::federation(
8217 url.to_string(),
8218 slot_id.to_string(),
8219 slot_token.to_string(),
8220 ))
8221 });
8222
8223 let local_endpoint = crate::endpoints::Endpoint::local(
8224 local_relay.trim_end_matches('/').to_string(),
8225 alloc.slot_id.clone(),
8226 alloc.slot_token.clone(),
8227 );
8228
8229 let mut endpoints: Vec<crate::endpoints::Endpoint> = Vec::new();
8230 if let Some(f) = fed_endpoint.clone() {
8231 endpoints.push(f);
8232 }
8233 endpoints.push(local_endpoint);
8234
8235 let (legacy_relay, legacy_slot_id, legacy_slot_token) = match fed_endpoint.clone() {
8245 Some(f) => (f.relay_url, f.slot_id, f.slot_token),
8246 None => (
8247 local_relay.trim_end_matches('/').to_string(),
8248 alloc.slot_id.clone(),
8249 alloc.slot_token.clone(),
8250 ),
8251 };
8252 let self_obj = state
8253 .as_object_mut()
8254 .expect("relay_state root is an object")
8255 .entry("self")
8256 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
8257 if !self_obj.is_object() {
8260 *self_obj = serde_json::Value::Object(serde_json::Map::new());
8261 }
8262 if let Some(obj) = self_obj.as_object_mut() {
8263 obj.insert("relay_url".into(), serde_json::Value::String(legacy_relay));
8264 obj.insert("slot_id".into(), serde_json::Value::String(legacy_slot_id));
8265 obj.insert(
8266 "slot_token".into(),
8267 serde_json::Value::String(legacy_slot_token),
8268 );
8269 obj.insert(
8270 "endpoints".into(),
8271 serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
8272 );
8273 }
8274
8275 if let Err(e) = std::fs::write(
8276 &state_path,
8277 serde_json::to_vec_pretty(&state).unwrap_or_default(),
8278 ) {
8279 eprintln!(
8280 "wire session new: persisting dual-slot relay_state at {state_path:?} failed: {e}"
8281 );
8282 return;
8283 }
8284 eprintln!(
8285 "wire session new: local slot allocated on {local_relay} (slot_id={})",
8286 alloc.slot_id
8287 );
8288}
8289
8290fn render_session_info(
8291 name: &str,
8292 session_home: &std::path::Path,
8293 cwd: &std::path::Path,
8294) -> Result<serde_json::Value> {
8295 let card_path = session_home
8296 .join("config")
8297 .join("wire")
8298 .join("agent-card.json");
8299 let (did, handle) = if card_path.exists() {
8300 let card: Value = serde_json::from_slice(&std::fs::read(&card_path)?)?;
8301 let did = card
8302 .get("did")
8303 .and_then(Value::as_str)
8304 .unwrap_or("")
8305 .to_string();
8306 let handle = card
8307 .get("handle")
8308 .and_then(Value::as_str)
8309 .map(str::to_string)
8310 .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
8311 (did, handle)
8312 } else {
8313 (String::new(), String::new())
8314 };
8315 Ok(json!({
8316 "name": name,
8317 "home_dir": session_home.to_string_lossy(),
8318 "cwd": cwd.to_string_lossy(),
8319 "did": did,
8320 "handle": handle,
8321 "export": format!("export WIRE_HOME={}", session_home.to_string_lossy()),
8322 }))
8323}
8324
8325fn emit_session_new_result(info: &serde_json::Value, status: &str, as_json: bool) -> Result<()> {
8326 if as_json {
8327 let mut obj = info.clone();
8328 obj["status"] = json!(status);
8329 println!("{}", serde_json::to_string(&obj)?);
8330 } else {
8331 let name = info["name"].as_str().unwrap_or("?");
8332 let handle = info["handle"].as_str().unwrap_or("?");
8333 let home = info["home_dir"].as_str().unwrap_or("?");
8334 let did = info["did"].as_str().unwrap_or("?");
8335 let export = info["export"].as_str().unwrap_or("?");
8336 let prefix = if status == "already_exists" {
8337 "session already exists (re-registered cwd)"
8338 } else {
8339 "session created"
8340 };
8341 println!(
8342 "{prefix}\n name: {name}\n handle: {handle}\n did: {did}\n home: {home}\n\nactivate with:\n {export}"
8343 );
8344 }
8345 Ok(())
8346}
8347
8348fn run_wire_with_home(
8349 session_home: &std::path::Path,
8350 args: &[&str],
8351) -> Result<std::process::ExitStatus> {
8352 let bin = std::env::current_exe().with_context(|| "locating self exe")?;
8353 let status = std::process::Command::new(&bin)
8354 .env("WIRE_HOME", session_home)
8355 .env_remove("RUST_LOG")
8356 .env("WIRE_AUTO_INIT", "0")
8359 .args(args)
8360 .status()
8361 .with_context(|| format!("spawning `wire {}`", args.join(" ")))?;
8362 Ok(status)
8363}
8364
8365pub fn maybe_auto_init_cwd_session(label: &str) {
8384 if std::env::var("WIRE_HOME").is_ok() {
8385 return; }
8387 if std::env::var("WIRE_AUTO_INIT").as_deref() == Ok("0") {
8388 return; }
8390 let cwd = match std::env::current_dir() {
8391 Ok(c) => c,
8392 Err(_) => return,
8393 };
8394 if crate::session::detect_session_wire_home(&cwd).is_some() {
8397 return;
8398 }
8399
8400 use fs2::FileExt;
8417 let sessions_root = match crate::session::sessions_root() {
8418 Ok(r) => r,
8419 Err(_) => return,
8420 };
8421 if let Err(e) = std::fs::create_dir_all(&sessions_root) {
8422 eprintln!("wire {label}: auto-init: failed to create sessions root {sessions_root:?}: {e}");
8423 return;
8424 }
8425 let lock_path = sessions_root.join(".auto-init.lock");
8426 let lock_file = match std::fs::OpenOptions::new()
8427 .create(true)
8428 .truncate(false)
8429 .read(true)
8430 .write(true)
8431 .open(&lock_path)
8432 {
8433 Ok(f) => f,
8434 Err(e) => {
8435 eprintln!(
8436 "wire {label}: auto-init: cannot open lockfile {lock_path:?}: {e} — falling back to default identity"
8437 );
8438 return;
8439 }
8440 };
8441 if let Err(e) = lock_file.lock_exclusive() {
8442 eprintln!(
8443 "wire {label}: auto-init: flock {lock_path:?} failed: {e} — falling back to default identity"
8444 );
8445 return;
8446 }
8447 let registry = crate::session::read_registry().unwrap_or_default();
8452 let name = crate::session::derive_name_from_cwd(&cwd, ®istry);
8453 let session_home = match crate::session::session_dir(&name) {
8454 Ok(h) => h,
8455 Err(_) => {
8456 let _ = fs2::FileExt::unlock(&lock_file);
8457 return;
8458 }
8459 };
8460 let agent_card_path = session_home
8461 .join("config")
8462 .join("wire")
8463 .join("agent-card.json");
8464 let needs_init = !agent_card_path.exists();
8465
8466 if needs_init {
8467 if let Err(e) = std::fs::create_dir_all(&session_home) {
8468 eprintln!(
8469 "wire {label}: auto-init: failed to create session dir {session_home:?}: {e}"
8470 );
8471 let _ = fs2::FileExt::unlock(&lock_file);
8472 return;
8473 }
8474 match run_wire_with_home(&session_home, &["init", &name, "--offline"]) {
8479 Ok(status) if status.success() => {}
8480 Ok(status) => {
8481 eprintln!(
8482 "wire {label}: auto-init: `wire init {name}` exited non-zero ({status}) — falling back to default identity"
8483 );
8484 let _ = fs2::FileExt::unlock(&lock_file);
8485 return;
8486 }
8487 Err(e) => {
8488 eprintln!(
8489 "wire {label}: auto-init: failed to spawn `wire init {name}`: {e:#} — falling back to default identity"
8490 );
8491 let _ = fs2::FileExt::unlock(&lock_file);
8492 return;
8493 }
8494 }
8495 try_allocate_local_slot(
8502 &session_home,
8503 &name,
8504 "https://wireup.net",
8505 "http://127.0.0.1:8771",
8506 );
8507 } else {
8508 if std::env::var("WIRE_QUIET_AUTOSESSION").is_err() {
8512 eprintln!(
8513 "wire {label}: auto-init: session `{name}` already exists (concurrent mcp peer won the race) — adopting"
8514 );
8515 }
8516 }
8517 let cwd_key = cwd.to_string_lossy().into_owned();
8527 let name_for_reg = name.clone();
8528 if let Err(e) = crate::session::update_registry(|reg| {
8529 reg.by_cwd.insert(cwd_key, name_for_reg);
8530 Ok(())
8531 }) {
8532 eprintln!("wire {label}: auto-init: failed to update registry: {e:#}");
8533 }
8535 let _ = fs2::FileExt::unlock(&lock_file);
8538
8539 if std::env::var("WIRE_QUIET_AUTOSESSION").is_err() {
8540 eprintln!(
8541 "wire {label}: auto-init: created session `{name}` for cwd `{}` → WIRE_HOME=`{}`",
8542 cwd.display(),
8543 session_home.display()
8544 );
8545 }
8546 unsafe {
8549 std::env::set_var("WIRE_HOME", &session_home);
8550 }
8551}
8552
8553fn ensure_session_daemon(session_home: &std::path::Path) -> Result<()> {
8554 let pidfile = session_home.join("state").join("wire").join("daemon.pid");
8557 if pidfile.exists() {
8558 let bytes = std::fs::read(&pidfile).unwrap_or_default();
8559 let pid: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
8560 v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
8561 } else {
8562 String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
8563 };
8564 if let Some(p) = pid {
8565 let alive = {
8566 #[cfg(target_os = "linux")]
8567 {
8568 std::path::Path::new(&format!("/proc/{p}")).exists()
8569 }
8570 #[cfg(not(target_os = "linux"))]
8571 {
8572 std::process::Command::new("kill")
8573 .args(["-0", &p.to_string()])
8574 .output()
8575 .map(|o| o.status.success())
8576 .unwrap_or(false)
8577 }
8578 };
8579 if alive {
8580 return Ok(());
8581 }
8582 }
8583 }
8584
8585 let bin = std::env::current_exe().with_context(|| "locating self exe")?;
8588 let log_path = session_home.join("state").join("wire").join("daemon.log");
8589 if let Some(parent) = log_path.parent() {
8590 std::fs::create_dir_all(parent).ok();
8591 }
8592 let log_file = std::fs::OpenOptions::new()
8593 .create(true)
8594 .append(true)
8595 .open(&log_path)
8596 .with_context(|| format!("opening daemon log {log_path:?}"))?;
8597 let log_err = log_file.try_clone()?;
8598 std::process::Command::new(&bin)
8599 .env("WIRE_HOME", session_home)
8600 .env_remove("RUST_LOG")
8601 .args(["daemon", "--interval", "5"])
8602 .stdout(log_file)
8603 .stderr(log_err)
8604 .stdin(std::process::Stdio::null())
8605 .spawn()
8606 .with_context(|| "spawning session-local `wire daemon`")?;
8607 Ok(())
8608}
8609
8610fn cmd_session_list(as_json: bool) -> Result<()> {
8611 let items = crate::session::list_sessions()?;
8612 if as_json {
8613 println!("{}", serde_json::to_string(&items)?);
8614 return Ok(());
8615 }
8616 if items.is_empty() {
8617 println!("no sessions on this machine. `wire session new` to create one.");
8618 return Ok(());
8619 }
8620 println!(
8621 "{:<22} {:<24} {:<24} {:<10} CWD",
8622 "PERSONA", "NAME", "HANDLE", "DAEMON"
8623 );
8624 for s in items {
8625 let plain = s
8629 .character
8630 .as_ref()
8631 .map(|c| c.short())
8632 .unwrap_or_else(|| "?".to_string());
8633 let colored = s
8634 .character
8635 .as_ref()
8636 .map(|c| c.colored())
8637 .unwrap_or_else(|| "?".to_string());
8638 let displayed_width = plain.chars().count() + 1; let pad = 22usize.saturating_sub(displayed_width);
8643 println!(
8644 "{}{} {:<24} {:<24} {:<10} {}",
8645 colored,
8646 " ".repeat(pad),
8647 s.name,
8648 s.handle.as_deref().unwrap_or("?"),
8649 if s.daemon_running { "running" } else { "down" },
8650 s.cwd.as_deref().unwrap_or("(no cwd registered)"),
8651 );
8652 }
8653 Ok(())
8654}
8655
8656fn cmd_session_list_local(as_json: bool) -> Result<()> {
8668 let listing = crate::session::list_local_sessions()?;
8669 if as_json {
8670 println!("{}", serde_json::to_string(&listing)?);
8671 return Ok(());
8672 }
8673
8674 if listing.local.is_empty() && listing.federation_only.is_empty() {
8675 println!(
8676 "no sessions on this machine. `wire session new --with-local` to create one \
8677 with a local-relay endpoint (start the relay first: \
8678 `wire relay-server --bind 127.0.0.1:8771 --local-only`)."
8679 );
8680 return Ok(());
8681 }
8682
8683 if listing.local.is_empty() {
8684 println!(
8685 "no sister sessions reachable via a local relay. \
8686 Re-run `wire session new --with-local` to add a Local endpoint, or \
8687 start a local relay with `wire relay-server --bind 127.0.0.1:8771 --local-only`."
8688 );
8689 } else {
8690 let mut keys: Vec<&String> = listing.local.keys().collect();
8692 keys.sort();
8693 for relay_url in keys {
8694 let group = &listing.local[relay_url];
8695 println!("LOCAL RELAY: {relay_url}");
8696 println!(" {:<24} {:<32} {:<10} CWD", "NAME", "HANDLE", "DAEMON");
8697 for s in group {
8698 println!(
8699 " {:<24} {:<32} {:<10} {}",
8700 s.name,
8701 s.handle.as_deref().unwrap_or("?"),
8702 if s.daemon_running { "running" } else { "down" },
8703 s.cwd.as_deref().unwrap_or("(no cwd registered)"),
8704 );
8705 }
8706 println!();
8707 }
8708 }
8709
8710 if !listing.federation_only.is_empty() {
8711 println!("federation-only (no local endpoint):");
8712 for s in &listing.federation_only {
8713 println!(
8714 " {:<24} {:<32} {}",
8715 s.name,
8716 s.handle.as_deref().unwrap_or("?"),
8717 s.cwd.as_deref().unwrap_or("(no cwd registered)"),
8718 );
8719 }
8720 }
8721 Ok(())
8722}
8723
8724fn cmd_session_pair_all_local(
8743 settle_secs: u64,
8744 federation_relay: &str,
8745 as_json: bool,
8746) -> Result<()> {
8747 use std::collections::BTreeSet;
8748 use std::time::Duration;
8749
8750 let listing = crate::session::list_local_sessions()?;
8751 let mut by_name: std::collections::BTreeMap<String, crate::session::LocalSessionView> =
8755 Default::default();
8756 for group in listing.local.into_values() {
8757 for s in group {
8758 by_name.entry(s.name.clone()).or_insert(s);
8759 }
8760 }
8761 let sessions: Vec<crate::session::LocalSessionView> = by_name.into_values().collect();
8762
8763 if sessions.len() < 2 {
8764 let msg = format!(
8765 "{} sister session(s) with a local endpoint — need at least 2 to pair.",
8766 sessions.len()
8767 );
8768 if as_json {
8769 println!(
8770 "{}",
8771 serde_json::to_string(&json!({
8772 "sessions": sessions.iter().map(|s| &s.name).collect::<Vec<_>>(),
8773 "pairs_attempted": 0,
8774 "pairs_succeeded": 0,
8775 "pairs_skipped_already_paired": 0,
8776 "pairs_failed": 0,
8777 "note": msg,
8778 }))?
8779 );
8780 } else {
8781 println!("{msg}");
8782 if let Some(s) = sessions.first() {
8783 println!(" - {} ({})", s.name, s.cwd.as_deref().unwrap_or("?"));
8784 }
8785 println!("Use `wire session new --with-local` to add more.");
8786 }
8787 return Ok(());
8788 }
8789
8790 let fed_host = host_of_url(federation_relay);
8791 if fed_host.is_empty() {
8792 bail!(
8793 "federation_relay `{federation_relay}` has no parseable host — \
8794 pass a full URL like `https://wireup.net`."
8795 );
8796 }
8797
8798 let mut attempted = 0u32;
8800 let mut succeeded = 0u32;
8801 let mut skipped_already = 0u32;
8802 let mut failed = 0u32;
8803 let mut per_pair: Vec<Value> = Vec::new();
8804
8805 for i in 0..sessions.len() {
8806 for j in (i + 1)..sessions.len() {
8807 let a = &sessions[i];
8808 let b = &sessions[j];
8809 attempted += 1;
8810
8811 let a_handle = a.handle.as_deref().unwrap_or(a.name.as_str());
8817 let b_handle = b.handle.as_deref().unwrap_or(b.name.as_str());
8818 let a_pinned_b = session_has_peer(&a.home_dir, b_handle);
8819 let b_pinned_a = session_has_peer(&b.home_dir, a_handle);
8820 if a_pinned_b && b_pinned_a {
8821 skipped_already += 1;
8822 per_pair.push(json!({
8823 "from": a.name,
8824 "to": b.name,
8825 "status": "already_paired",
8826 }));
8827 continue;
8828 }
8829
8830 let pair_result = drive_bilateral_pair(
8831 &a.home_dir,
8832 &a.name,
8833 &b.home_dir,
8834 &b.name,
8835 &fed_host,
8836 federation_relay,
8837 settle_secs,
8838 );
8839
8840 match pair_result {
8841 Ok(()) => {
8842 succeeded += 1;
8843 per_pair.push(json!({
8844 "from": a.name,
8845 "to": b.name,
8846 "status": "paired",
8847 }));
8848 }
8849 Err(e) => {
8850 failed += 1;
8851 let detail = format!("{e:#}");
8852 per_pair.push(json!({
8853 "from": a.name,
8854 "to": b.name,
8855 "status": "failed",
8856 "error": detail,
8857 }));
8858 }
8859 }
8860
8861 std::thread::sleep(Duration::from_millis(200));
8864 }
8865 }
8866
8867 let _ = BTreeSet::<String>::new(); let summary = json!({
8869 "sessions": sessions.iter().map(|s| s.name.clone()).collect::<Vec<_>>(),
8870 "pairs_attempted": attempted,
8871 "pairs_succeeded": succeeded,
8872 "pairs_skipped_already_paired": skipped_already,
8873 "pairs_failed": failed,
8874 "results": per_pair,
8875 });
8876 if as_json {
8877 println!("{}", serde_json::to_string(&summary)?);
8878 } else {
8879 println!(
8880 "wire session pair-all-local: {} session(s), {} pair(s) attempted",
8881 sessions.len(),
8882 attempted
8883 );
8884 println!(" paired: {succeeded}");
8885 println!(" skipped (already pinned): {skipped_already}");
8886 println!(" failed: {failed}");
8887 for entry in summary["results"].as_array().unwrap_or(&vec![]) {
8888 let from = entry["from"].as_str().unwrap_or("?");
8889 let to = entry["to"].as_str().unwrap_or("?");
8890 let status = entry["status"].as_str().unwrap_or("?");
8891 let err = entry.get("error").and_then(Value::as_str).unwrap_or("");
8892 if err.is_empty() {
8893 println!(" {from:<24} ↔ {to:<24} {status}");
8894 } else {
8895 println!(" {from:<24} ↔ {to:<24} {status} — {err}");
8896 }
8897 }
8898 }
8899 Ok(())
8900}
8901
8902fn session_has_peer(session_home: &std::path::Path, peer_name: &str) -> bool {
8905 val_session_relay_state(session_home)
8906 .and_then(|v| v.get("peers").cloned())
8907 .and_then(|p| p.get(peer_name).cloned())
8908 .is_some()
8909}
8910
8911fn val_session_relay_state(session_home: &std::path::Path) -> Option<Value> {
8916 let path = session_home.join("config").join("wire").join("relay.json");
8917 let bytes = std::fs::read(&path).ok()?;
8918 serde_json::from_slice(&bytes).ok()
8919}
8920
8921fn cmd_session_mesh_status(stale_secs: u64, as_json: bool) -> Result<()> {
8925 use std::collections::BTreeMap;
8926
8927 let listing = crate::session::list_local_sessions()?;
8930 let mut by_name: BTreeMap<String, crate::session::LocalSessionView> = BTreeMap::new();
8931 for group in listing.local.into_values() {
8932 for s in group {
8933 by_name.entry(s.name.clone()).or_insert(s);
8934 }
8935 }
8936 let sessions: Vec<crate::session::LocalSessionView> = by_name.into_values().collect();
8937 let federation_only = listing.federation_only;
8938
8939 if sessions.is_empty() {
8940 let msg = "no sister sessions with a local endpoint on this machine.".to_string();
8941 if as_json {
8942 println!(
8943 "{}",
8944 serde_json::to_string(&json!({
8945 "sessions": [],
8946 "edges": [],
8947 "local_relay": null,
8948 "federation_only": federation_only.iter().map(|f| &f.name).collect::<Vec<_>>(),
8949 "summary": {
8950 "session_count": 0,
8951 "edge_count": 0,
8952 "healthy": 0,
8953 "stale": 0,
8954 "asymmetric": 0,
8955 },
8956 "note": msg,
8957 }))?
8958 );
8959 } else {
8960 println!("{msg}");
8961 println!("Use `wire session new --with-local` to create one.");
8962 }
8963 return Ok(());
8964 }
8965
8966 struct SessionState {
8968 view: crate::session::LocalSessionView,
8969 relay_state: Value,
8970 local_relay_url: Option<String>,
8971 }
8972 let mut sstates: Vec<SessionState> = Vec::with_capacity(sessions.len());
8973 for s in sessions {
8974 let relay_state = val_session_relay_state(&s.home_dir)
8975 .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
8976 let local_relay_url = s.local_endpoints.first().map(|e| e.relay_url.clone());
8977 sstates.push(SessionState {
8978 view: s,
8979 relay_state,
8980 local_relay_url,
8981 });
8982 }
8983
8984 let mut local_relays: BTreeMap<String, bool> = BTreeMap::new();
8987 for s in &sstates {
8988 if let Some(url) = &s.local_relay_url
8989 && !local_relays.contains_key(url)
8990 {
8991 let healthy = probe_relay_healthz(url);
8992 local_relays.insert(url.clone(), healthy);
8993 }
8994 }
8995
8996 let now = std::time::SystemTime::now()
8997 .duration_since(std::time::UNIX_EPOCH)
8998 .map(|d| d.as_secs())
8999 .unwrap_or(0);
9000
9001 let mut edges: Vec<Value> = Vec::new();
9005 let mut healthy_count = 0u32;
9006 let mut stale_count = 0u32;
9007 let mut asymmetric_count = 0u32;
9008
9009 for i in 0..sstates.len() {
9010 for j in (i + 1)..sstates.len() {
9011 let a = &sstates[i];
9012 let b = &sstates[j];
9013 let b_key = b.view.handle.as_deref().unwrap_or(b.view.name.as_str());
9018 let a_key = a.view.handle.as_deref().unwrap_or(a.view.name.as_str());
9019 let a_to_b = probe_directed_edge(&a.relay_state, b_key, now);
9020 let b_to_a = probe_directed_edge(&b.relay_state, a_key, now);
9021
9022 let bilateral = a_to_b.pinned && b_to_a.pinned;
9023 let scope = match (a_to_b.scope.as_deref(), b_to_a.scope.as_deref()) {
9027 (Some("local"), _) | (_, Some("local")) => "local",
9028 (Some("federation"), _) | (_, Some("federation")) => "federation",
9029 _ => "unknown",
9030 };
9031
9032 let mut status = if bilateral { "healthy" } else { "asymmetric" };
9035 if bilateral {
9036 let either_stale = [&a_to_b, &b_to_a].iter().any(|d| match d.silent_secs {
9037 Some(s) => s > stale_secs,
9038 None => d.probed,
9039 });
9040 if either_stale {
9041 status = "stale";
9042 }
9043 }
9044
9045 match status {
9046 "healthy" => healthy_count += 1,
9047 "stale" => stale_count += 1,
9048 "asymmetric" => asymmetric_count += 1,
9049 _ => {}
9050 }
9051
9052 edges.push(json!({
9053 "from": a.view.name,
9054 "to": b.view.name,
9055 "bilateral": bilateral,
9056 "scope": scope,
9057 "status": status,
9058 "directions": {
9059 a.view.name.clone(): direction_summary(&a_to_b),
9060 b.view.name.clone(): direction_summary(&b_to_a),
9061 },
9062 }));
9063 }
9064 }
9065
9066 let summary = json!({
9067 "sessions": sstates.iter().map(|s| json!({
9068 "name": s.view.name,
9069 "handle": s.view.handle,
9070 "cwd": s.view.cwd,
9071 "daemon_running": s.view.daemon_running,
9072 "local_relay": s.local_relay_url,
9073 })).collect::<Vec<_>>(),
9074 "edges": edges,
9075 "local_relays": local_relays.iter().map(|(url, healthy)| json!({
9076 "url": url,
9077 "healthy": healthy,
9078 })).collect::<Vec<_>>(),
9079 "federation_only": federation_only.iter().map(|f| &f.name).collect::<Vec<_>>(),
9080 "summary": {
9081 "session_count": sstates.len(),
9082 "edge_count": edges.len(),
9083 "healthy": healthy_count,
9084 "stale": stale_count,
9085 "asymmetric": asymmetric_count,
9086 "stale_threshold_secs": stale_secs,
9087 },
9088 });
9089
9090 if as_json {
9091 println!("{}", serde_json::to_string(&summary)?);
9092 return Ok(());
9093 }
9094
9095 println!(
9096 "wire mesh: {} session(s), {} edge(s)",
9097 sstates.len(),
9098 edges.len()
9099 );
9100 for (url, healthy) in &local_relays {
9101 let tick = if *healthy { "✓" } else { "✗" };
9102 println!(" local-relay {url} {tick}");
9103 }
9104 if !federation_only.is_empty() {
9105 print!(" federation-only sessions:");
9106 for f in &federation_only {
9107 print!(" {}", f.name);
9108 }
9109 println!();
9110 }
9111
9112 let names: Vec<&str> = sstates.iter().map(|s| s.view.name.as_str()).collect();
9114 let col_w = names.iter().map(|n| n.len()).max().unwrap_or(8).max(7) + 1;
9115 print!("\n{:>col_w$}", "", col_w = col_w);
9116 for n in &names {
9117 print!("{:>col_w$}", n, col_w = col_w);
9118 }
9119 println!();
9120 for (i, row) in names.iter().enumerate() {
9121 print!("{:>col_w$}", row, col_w = col_w);
9122 for (j, col) in names.iter().enumerate() {
9123 let cell = if i == j {
9124 "self".to_string()
9125 } else {
9126 let d = probe_directed_edge(&sstates[i].relay_state, col, now);
9127 match d.scope.as_deref() {
9128 Some("local") => "local".to_string(),
9129 Some("federation") => "fed".to_string(),
9130 _ => "—".to_string(),
9131 }
9132 };
9133 print!("{:>col_w$}", cell, col_w = col_w);
9134 }
9135 println!();
9136 }
9137
9138 println!("\nHealth (stale threshold: {stale_secs}s):");
9139 for e in &edges {
9140 let from = e["from"].as_str().unwrap_or("?");
9141 let to = e["to"].as_str().unwrap_or("?");
9142 let scope = e["scope"].as_str().unwrap_or("?");
9143 let status = e["status"].as_str().unwrap_or("?");
9144 let mark = match status {
9145 "healthy" => "✓",
9146 "stale" => "⚠",
9147 "asymmetric" => "!",
9148 _ => "?",
9149 };
9150 let dirs = e["directions"].as_object().cloned().unwrap_or_default();
9151 let mut details: Vec<String> = Vec::new();
9152 for (who, d) in &dirs {
9153 let silent = d.get("silent_secs").and_then(Value::as_u64);
9154 let pinned = d.get("pinned").and_then(Value::as_bool).unwrap_or(false);
9155 let probed = d.get("probed").and_then(Value::as_bool).unwrap_or(false);
9156 let label = match (pinned, probed, silent) {
9157 (false, _, _) => format!("{who} has not pinned"),
9158 (true, false, _) => format!("{who} pinned but no endpoint to probe"),
9159 (true, true, Some(s)) if s <= stale_secs => format!("{who} fresh ({s}s)"),
9160 (true, true, Some(s)) => format!("{who} silent {s}s"),
9161 (true, true, None) => format!("{who} never pulled"),
9162 };
9163 details.push(label);
9164 }
9165 println!(
9166 " {mark} {from} ↔ {to} scope={scope} {status:>10} [{}]",
9167 details.join(" | ")
9168 );
9169 }
9170 Ok(())
9171}
9172
9173#[derive(Default)]
9174struct DirectedEdge {
9175 pinned: bool,
9176 scope: Option<String>,
9177 last_pull_at_unix: Option<u64>,
9178 silent_secs: Option<u64>,
9179 probed: bool,
9180 event_count: usize,
9181}
9182
9183fn probe_directed_edge(from_state: &Value, to_name: &str, now: u64) -> DirectedEdge {
9189 let pinned = from_state
9190 .get("peers")
9191 .and_then(|p| p.get(to_name))
9192 .is_some();
9193 if !pinned {
9194 return DirectedEdge::default();
9195 }
9196 let endpoints = crate::endpoints::peer_endpoints_in_priority_order(from_state, to_name);
9197 let ep = match endpoints.into_iter().next() {
9198 Some(e) => e,
9199 None => {
9200 return DirectedEdge {
9201 pinned: true,
9202 ..Default::default()
9203 };
9204 }
9205 };
9206 let scope = Some(
9207 match ep.scope {
9208 crate::endpoints::EndpointScope::Local => "local",
9209 crate::endpoints::EndpointScope::Lan => "lan",
9210 crate::endpoints::EndpointScope::Uds => "uds",
9211 crate::endpoints::EndpointScope::Federation => "federation",
9212 }
9213 .to_string(),
9214 );
9215 let client = crate::relay_client::RelayClient::new(&ep.relay_url);
9216 let (count, last) = client
9217 .slot_state(&ep.slot_id, &ep.slot_token)
9218 .unwrap_or((0, None));
9219 let silent = last.map(|t| now.saturating_sub(t));
9220 DirectedEdge {
9221 pinned: true,
9222 scope,
9223 last_pull_at_unix: last,
9224 silent_secs: silent,
9225 probed: true,
9226 event_count: count,
9227 }
9228}
9229
9230fn direction_summary(d: &DirectedEdge) -> Value {
9231 json!({
9232 "pinned": d.pinned,
9233 "scope": d.scope,
9234 "probed": d.probed,
9235 "last_pull_at_unix": d.last_pull_at_unix,
9236 "silent_secs": d.silent_secs,
9237 "event_count": d.event_count,
9238 })
9239}
9240
9241fn probe_relay_healthz(url: &str) -> bool {
9243 let probe_url = format!("{}/healthz", url.trim_end_matches('/'));
9244 let client = match reqwest::blocking::Client::builder()
9245 .timeout(std::time::Duration::from_millis(500))
9246 .build()
9247 {
9248 Ok(c) => c,
9249 Err(_) => return false,
9250 };
9251 match client.get(&probe_url).send() {
9252 Ok(r) => r.status().is_success(),
9253 Err(_) => false,
9254 }
9255}
9256
9257fn drive_bilateral_pair(
9272 a_home: &std::path::Path,
9273 a_name: &str,
9274 b_home: &std::path::Path,
9275 b_name: &str,
9276 _fed_host: &str,
9277 _federation_relay: &str,
9278 settle_secs: u64,
9279) -> Result<()> {
9280 use std::time::Duration;
9281 let bin = std::env::current_exe().context("locating self exe")?;
9282
9283 let run = |home: &std::path::Path, args: &[&str]| -> Result<()> {
9284 let out = std::process::Command::new(&bin)
9285 .env("WIRE_HOME", home)
9286 .env_remove("RUST_LOG")
9287 .args(args)
9288 .output()
9289 .with_context(|| format!("spawning `wire {}`", args.join(" ")))?;
9290 if !out.status.success() {
9291 bail!(
9292 "`wire {}` failed: stderr={}",
9293 args.join(" "),
9294 String::from_utf8_lossy(&out.stderr).trim()
9295 );
9296 }
9297 Ok(())
9298 };
9299
9300 let read_card_handle = |home: &std::path::Path| -> Result<String> {
9305 let card_path = home.join("config").join("wire").join("agent-card.json");
9306 let bytes = std::fs::read(&card_path)
9307 .with_context(|| format!("reading agent-card at {card_path:?}"))?;
9308 let card: Value = serde_json::from_slice(&bytes)?;
9309 card.get("handle")
9310 .and_then(Value::as_str)
9311 .map(str::to_string)
9312 .ok_or_else(|| anyhow!("agent-card at {card_path:?} missing `handle` field"))
9313 };
9314 let a_handle = read_card_handle(a_home)
9315 .with_context(|| format!("session {a_name} (a): read agent-card.handle"))?;
9316 let b_handle = read_card_handle(b_home)
9317 .with_context(|| format!("session {b_name} (b): read agent-card.handle"))?;
9318
9319 run(a_home, &["add", b_name, "--local-sister", "--json"])
9323 .with_context(|| format!("step 1/8: {a_name} `wire add {b_name} --local-sister`"))?;
9324
9325 std::thread::sleep(Duration::from_secs(settle_secs));
9327
9328 run(b_home, &["pull", "--json"]).with_context(|| format!("step 4/8: {b_name} `wire pull`"))?;
9331 run(b_home, &["pair-accept", &a_handle, "--json"]).with_context(|| {
9332 format!("step 5/8: {b_name} `wire pair-accept {a_handle}` (a session={a_name})")
9333 })?;
9334 run(b_home, &["push", "--json"]).with_context(|| format!("step 6/8: {b_name} `wire push`"))?;
9335
9336 std::thread::sleep(Duration::from_secs(settle_secs));
9338
9339 run(a_home, &["pull", "--json"]).with_context(|| format!("step 8/8: {a_name} `wire pull`"))?;
9341 let _ = &b_handle;
9343
9344 Ok(())
9345}
9346
9347fn cmd_session_env(name_arg: Option<&str>, as_json: bool) -> Result<()> {
9348 let name = resolve_session_name(name_arg)?;
9349 let session_home = crate::session::session_dir(&name)?;
9350 if !session_home.exists() {
9351 bail!(
9352 "no session named {name:?} on this machine. `wire session list` to enumerate, \
9353 `wire session new {name}` to create."
9354 );
9355 }
9356 if as_json {
9357 println!(
9358 "{}",
9359 serde_json::to_string(&json!({
9360 "name": name,
9361 "home_dir": session_home.to_string_lossy(),
9362 "export": format!("export WIRE_HOME={}", session_home.to_string_lossy()),
9363 }))?
9364 );
9365 } else {
9366 println!("export WIRE_HOME={}", session_home.to_string_lossy());
9367 }
9368 Ok(())
9369}
9370
9371fn cmd_session_current(as_json: bool) -> Result<()> {
9372 let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
9373 let registry = crate::session::read_registry().unwrap_or_default();
9374 let cwd_key = cwd.to_string_lossy().into_owned();
9375 let name = registry.by_cwd.get(&cwd_key).cloned();
9376 if as_json {
9377 println!(
9378 "{}",
9379 serde_json::to_string(&json!({
9380 "cwd": cwd_key,
9381 "session": name,
9382 }))?
9383 );
9384 } else if let Some(n) = name {
9385 println!("{n}");
9386 } else {
9387 println!("(no session registered for this cwd)");
9388 }
9389 Ok(())
9390}
9391
9392fn cmd_session_destroy(name_arg: &str, force: bool, as_json: bool) -> Result<()> {
9393 let name = crate::session::sanitize_name(name_arg);
9394 let session_home = crate::session::session_dir(&name)?;
9395 if !session_home.exists() {
9396 if as_json {
9397 println!(
9398 "{}",
9399 serde_json::to_string(&json!({
9400 "name": name,
9401 "destroyed": false,
9402 "reason": "no such session",
9403 }))?
9404 );
9405 } else {
9406 println!("no session named {name:?} — nothing to destroy.");
9407 }
9408 return Ok(());
9409 }
9410 if !force {
9411 bail!(
9412 "destroying session {name:?} would delete its keypair + state irrecoverably. \
9413 Pass --force to confirm."
9414 );
9415 }
9416
9417 let pidfile = session_home.join("state").join("wire").join("daemon.pid");
9419 if let Ok(bytes) = std::fs::read(&pidfile) {
9420 let pid: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
9421 v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
9422 } else {
9423 String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
9424 };
9425 if let Some(p) = pid {
9426 let _ = std::process::Command::new("kill")
9427 .args(["-TERM", &p.to_string()])
9428 .output();
9429 }
9430 }
9431
9432 std::fs::remove_dir_all(&session_home)
9433 .with_context(|| format!("removing session dir {session_home:?}"))?;
9434
9435 let mut registry = crate::session::read_registry().unwrap_or_default();
9437 registry.by_cwd.retain(|_, v| v != &name);
9438 crate::session::write_registry(®istry)?;
9439
9440 if as_json {
9441 println!(
9442 "{}",
9443 serde_json::to_string(&json!({
9444 "name": name,
9445 "destroyed": true,
9446 }))?
9447 );
9448 } else {
9449 println!("destroyed session {name:?}.");
9450 }
9451 Ok(())
9452}
9453
9454fn cmd_diag(action: DiagAction) -> Result<()> {
9457 let state = config::state_dir()?;
9458 let knob = state.join("diag.enabled");
9459 let log_path = state.join("diag.jsonl");
9460 match action {
9461 DiagAction::Tail { limit, json } => {
9462 let entries = crate::diag::tail(limit);
9463 if json {
9464 for e in entries {
9465 println!("{}", serde_json::to_string(&e)?);
9466 }
9467 } else if entries.is_empty() {
9468 println!("wire diag: no entries (diag may be disabled — `wire diag enable`)");
9469 } else {
9470 for e in entries {
9471 let ts = e["ts"].as_u64().unwrap_or(0);
9472 let ty = e["type"].as_str().unwrap_or("?");
9473 let pid = e["pid"].as_u64().unwrap_or(0);
9474 let payload = e["payload"].to_string();
9475 println!("[{ts}] pid={pid} {ty} {payload}");
9476 }
9477 }
9478 }
9479 DiagAction::Enable => {
9480 config::ensure_dirs()?;
9481 std::fs::write(&knob, "1")?;
9482 println!("wire diag: enabled at {knob:?}");
9483 }
9484 DiagAction::Disable => {
9485 if knob.exists() {
9486 std::fs::remove_file(&knob)?;
9487 }
9488 println!("wire diag: disabled (env WIRE_DIAG may still flip it on per-process)");
9489 }
9490 DiagAction::Status { json } => {
9491 let enabled = crate::diag::is_enabled();
9492 let size = std::fs::metadata(&log_path).map(|m| m.len()).unwrap_or(0);
9493 if json {
9494 println!(
9495 "{}",
9496 serde_json::to_string(&serde_json::json!({
9497 "enabled": enabled,
9498 "log_path": log_path,
9499 "log_size_bytes": size,
9500 }))?
9501 );
9502 } else {
9503 println!("wire diag status");
9504 println!(" enabled: {enabled}");
9505 println!(" log: {log_path:?}");
9506 println!(" log size: {size} bytes");
9507 }
9508 }
9509 }
9510 Ok(())
9511}
9512
9513fn cmd_service(action: ServiceAction) -> Result<()> {
9516 let kind = |local_relay: bool| {
9517 if local_relay {
9518 crate::service::ServiceKind::LocalRelay
9519 } else {
9520 crate::service::ServiceKind::Daemon
9521 }
9522 };
9523 let (report, as_json) = match action {
9524 ServiceAction::Install { local_relay, json } => {
9525 (crate::service::install_kind(kind(local_relay))?, json)
9526 }
9527 ServiceAction::Uninstall { local_relay, json } => {
9528 (crate::service::uninstall_kind(kind(local_relay))?, json)
9529 }
9530 ServiceAction::Status { local_relay, json } => {
9531 (crate::service::status_kind(kind(local_relay))?, json)
9532 }
9533 };
9534 if as_json {
9535 println!("{}", serde_json::to_string(&report)?);
9536 } else {
9537 println!("wire service {}", report.action);
9538 println!(" platform: {}", report.platform);
9539 println!(" unit: {}", report.unit_path);
9540 println!(" status: {}", report.status);
9541 println!(" detail: {}", report.detail);
9542 }
9543 Ok(())
9544}
9545
9546fn cmd_upgrade(check_only: bool, as_json: bool) -> Result<()> {
9561 let daemon_pids: Vec<u32> = crate::platform::find_processes_by_cmdline("wire daemon");
9570 let relay_pids: Vec<u32> = crate::platform::find_processes_by_cmdline("wire relay-server");
9571 let running_pids: Vec<u32> = daemon_pids
9572 .iter()
9573 .chain(relay_pids.iter())
9574 .copied()
9575 .collect();
9576
9577 let record = crate::ensure_up::read_pid_record("daemon");
9579 let recorded_version: Option<String> = match &record {
9580 crate::ensure_up::PidRecord::Json(d) => Some(d.version.clone()),
9581 crate::ensure_up::PidRecord::LegacyInt(_) => Some("<pre-0.5.11>".to_string()),
9582 _ => None,
9583 };
9584 let cli_version = env!("CARGO_PKG_VERSION").to_string();
9585
9586 let sessions_to_respawn_after_kill: Vec<std::path::PathBuf> = crate::session::list_sessions()
9593 .unwrap_or_default()
9594 .into_iter()
9595 .filter(|s| s.daemon_running)
9596 .map(|s| s.home_dir)
9597 .collect();
9598
9599 if check_only {
9600 let sessions_with_daemons: Vec<String> = crate::session::list_sessions()
9602 .unwrap_or_default()
9603 .iter()
9604 .filter(|s| s.daemon_running)
9605 .map(|s| s.name.clone())
9606 .collect();
9607 let mut path_dupes: Vec<String> = Vec::new();
9608 if let Ok(path) = std::env::var("PATH") {
9609 let mut seen: std::collections::HashSet<std::path::PathBuf> =
9610 std::collections::HashSet::new();
9611 for dir in path.split(':') {
9612 let candidate = std::path::PathBuf::from(dir).join("wire");
9613 if candidate.exists() {
9614 let canon = candidate.canonicalize().unwrap_or(candidate);
9615 if seen.insert(canon.clone()) {
9616 path_dupes.push(canon.to_string_lossy().into_owned());
9617 }
9618 }
9619 }
9620 }
9621 let installed_service_kinds: Vec<&'static str> = [
9624 (crate::service::ServiceKind::Daemon, "daemon"),
9625 (crate::service::ServiceKind::LocalRelay, "local-relay"),
9626 ]
9627 .into_iter()
9628 .filter_map(|(k, label)| {
9629 crate::service::status_kind(k)
9630 .ok()
9631 .filter(|r| r.status != "absent")
9632 .map(|_| label)
9633 })
9634 .collect();
9635 let report = json!({
9636 "running_pids": running_pids,
9637 "running_daemons": daemon_pids,
9638 "running_relay_servers": relay_pids,
9639 "pidfile_version": recorded_version,
9640 "cli_version": cli_version,
9641 "would_kill": running_pids,
9642 "would_refresh_services": installed_service_kinds,
9643 "session_daemons_running": sessions_with_daemons,
9644 "path_binaries": path_dupes,
9645 "path_duplicate_warning": path_dupes.len() > 1,
9646 });
9647 if as_json {
9648 println!("{}", serde_json::to_string(&report)?);
9649 } else {
9650 println!("wire upgrade --check");
9651 println!(" cli version: {cli_version}");
9652 println!(
9653 " pidfile version: {}",
9654 recorded_version.as_deref().unwrap_or("(missing)")
9655 );
9656 if running_pids.is_empty() {
9657 println!(" running daemons: none");
9658 println!(" running relays: none");
9659 } else {
9660 if daemon_pids.is_empty() {
9661 println!(" running daemons: none");
9662 } else {
9663 let p: Vec<String> = daemon_pids.iter().map(|p| p.to_string()).collect();
9664 println!(" running daemons: pids {}", p.join(", "));
9665 }
9666 if relay_pids.is_empty() {
9667 println!(" running relays: none");
9668 } else {
9669 let p: Vec<String> = relay_pids.iter().map(|p| p.to_string()).collect();
9670 println!(" running relays: pids {}", p.join(", "));
9671 }
9672 println!(" would kill all + spawn fresh");
9673 }
9674 if !installed_service_kinds.is_empty() {
9675 println!(
9676 " would refresh: {} installed service unit(s) → new binary path",
9677 installed_service_kinds.join(", ")
9678 );
9679 }
9680 if !sessions_with_daemons.is_empty() {
9681 println!(
9682 " session daemons: {} (would respawn under new binary)",
9683 sessions_with_daemons.join(", ")
9684 );
9685 }
9686 if path_dupes.len() > 1 {
9687 println!(
9688 " PATH warning: {} distinct `wire` binaries on PATH:",
9689 path_dupes.len()
9690 );
9691 for b in &path_dupes {
9692 println!(" {b}");
9693 }
9694 println!(" operators should remove the stale ones");
9695 }
9696 }
9697 return Ok(());
9698 }
9699
9700 let mut killed: Vec<u32> = Vec::new();
9707 for pid in &running_pids {
9708 if crate::platform::kill_process(*pid, false) {
9709 killed.push(*pid);
9710 }
9711 }
9712 if !killed.is_empty() {
9714 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
9715 loop {
9716 let still_alive: Vec<u32> = killed
9717 .iter()
9718 .copied()
9719 .filter(|p| process_alive_pid(*p))
9720 .collect();
9721 if still_alive.is_empty() {
9722 break;
9723 }
9724 if std::time::Instant::now() >= deadline {
9725 for pid in still_alive {
9727 let _ = crate::platform::kill_process(pid, true);
9728 }
9729 break;
9730 }
9731 std::thread::sleep(std::time::Duration::from_millis(50));
9732 }
9733 }
9734
9735 let pidfile = config::state_dir()?.join("daemon.pid");
9738 if pidfile.exists() {
9739 let _ = std::fs::remove_file(&pidfile);
9740 }
9741
9742 if let Ok(sessions) = crate::session::list_sessions() {
9749 for s in &sessions {
9750 let session_pidfile = s.home_dir.join("state").join("wire").join("daemon.pid");
9751 if session_pidfile.exists() {
9752 let _ = std::fs::remove_file(&session_pidfile);
9753 }
9754 }
9755 }
9756 let session_daemons_to_respawn = sessions_to_respawn_after_kill;
9757
9758 let mut path_dupes: Vec<String> = Vec::new();
9763 if let Ok(path) = std::env::var("PATH") {
9764 let mut seen: std::collections::HashSet<std::path::PathBuf> =
9765 std::collections::HashSet::new();
9766 for dir in path.split(':') {
9767 let candidate = std::path::PathBuf::from(dir).join("wire");
9768 if candidate.exists() {
9769 let canon = candidate.canonicalize().unwrap_or(candidate);
9770 if seen.insert(canon.clone()) {
9771 path_dupes.push(canon.to_string_lossy().into_owned());
9772 }
9773 }
9774 }
9775 }
9776 let path_warning = if path_dupes.len() > 1 {
9777 Some(format!(
9778 "WARN: {} distinct `wire` binaries on PATH — old versions can shadow the fresh install:\n {}",
9779 path_dupes.len(),
9780 path_dupes.join("\n ")
9781 ))
9782 } else {
9783 None
9784 };
9785
9786 let mut service_refreshes: Vec<Value> = Vec::new();
9800 for kind in [
9801 crate::service::ServiceKind::Daemon,
9802 crate::service::ServiceKind::LocalRelay,
9803 ] {
9804 let already_installed = crate::service::status_kind(kind)
9805 .map(|r| r.status != "absent")
9806 .unwrap_or(false);
9807 if !already_installed {
9808 continue;
9809 }
9810 match crate::service::install_kind(kind) {
9811 Ok(rep) => service_refreshes.push(json!({
9812 "kind": rep.kind,
9813 "platform": rep.platform,
9814 "status": rep.status,
9815 "unit_path": rep.unit_path,
9816 "action": "refreshed",
9817 })),
9818 Err(e) => service_refreshes.push(json!({
9819 "kind": format!("{kind:?}"),
9820 "action": "refresh_failed",
9821 "error": format!("{e:#}"),
9822 })),
9823 }
9824 }
9825
9826 let spawned = crate::ensure_up::ensure_daemon_running()?;
9832
9833 let mut session_respawns: Vec<Value> = Vec::new();
9839 for home in &session_daemons_to_respawn {
9840 match ensure_session_daemon(home) {
9841 Ok(()) => session_respawns.push(json!({
9842 "session_home": home.to_string_lossy(),
9843 "status": "respawned",
9844 })),
9845 Err(e) => session_respawns.push(json!({
9846 "session_home": home.to_string_lossy(),
9847 "status": "failed",
9848 "error": format!("{e:#}"),
9849 })),
9850 }
9851 }
9852
9853 let new_record = crate::ensure_up::read_pid_record("daemon");
9854 let new_pid = new_record.pid();
9855 let new_version: Option<String> = if let crate::ensure_up::PidRecord::Json(d) = &new_record {
9856 Some(d.version.clone())
9857 } else {
9858 None
9859 };
9860
9861 if as_json {
9862 println!(
9863 "{}",
9864 serde_json::to_string(&json!({
9865 "killed": killed,
9866 "killed_daemons": daemon_pids,
9867 "killed_relay_servers": relay_pids,
9868 "service_refreshes": service_refreshes,
9869 "spawned_fresh_daemon": spawned,
9870 "new_pid": new_pid,
9871 "new_version": new_version,
9872 "cli_version": cli_version,
9873 "session_respawns": session_respawns,
9874 "path_binaries": path_dupes,
9875 "path_warning": path_warning,
9876 }))?
9877 );
9878 } else {
9879 if killed.is_empty() {
9880 println!("wire upgrade: no stale wire processes running");
9881 } else {
9882 println!(
9883 "wire upgrade: killed {} process(es) — {} daemon(s) + {} relay-server(s) (pids {})",
9884 killed.len(),
9885 daemon_pids.len(),
9886 relay_pids.len(),
9887 killed
9888 .iter()
9889 .map(|p| p.to_string())
9890 .collect::<Vec<_>>()
9891 .join(", ")
9892 );
9893 }
9894 if !service_refreshes.is_empty() {
9895 println!(
9896 "wire upgrade: refreshed {} installed service unit(s) to point at the new binary:",
9897 service_refreshes.len()
9898 );
9899 for r in &service_refreshes {
9900 let kind = r.get("kind").and_then(Value::as_str).unwrap_or("?");
9901 let action = r.get("action").and_then(Value::as_str).unwrap_or("?");
9902 let status = r.get("status").and_then(Value::as_str).unwrap_or("");
9903 let platform = r.get("platform").and_then(Value::as_str).unwrap_or("");
9904 if action == "refreshed" {
9905 println!(" - {kind}: {action} ({status}, {platform})");
9906 } else {
9907 let err = r.get("error").and_then(Value::as_str).unwrap_or("");
9908 println!(" - {kind}: {action} ({err})");
9909 }
9910 }
9911 }
9912 if spawned {
9913 println!(
9914 "wire upgrade: spawned fresh daemon (pid {} v{})",
9915 new_pid
9916 .map(|p| p.to_string())
9917 .unwrap_or_else(|| "?".to_string()),
9918 new_version.as_deref().unwrap_or(&cli_version),
9919 );
9920 } else {
9921 println!("wire upgrade: daemon was already running on current binary");
9922 }
9923 if !session_respawns.is_empty() {
9924 println!(
9925 "wire upgrade: refreshed {} session daemon(s):",
9926 session_respawns.len()
9927 );
9928 for r in &session_respawns {
9929 let h = r["session_home"].as_str().unwrap_or("?");
9930 let s = r["status"].as_str().unwrap_or("?");
9931 let label = std::path::Path::new(h)
9932 .file_name()
9933 .map(|f| f.to_string_lossy().into_owned())
9934 .unwrap_or_else(|| h.to_string());
9935 println!(" {label:<24} {s}");
9936 }
9937 }
9938 if let Some(msg) = &path_warning {
9939 eprintln!("wire upgrade: {msg}");
9940 }
9941 }
9942 Ok(())
9943}
9944
9945fn json_default(explicit: bool) -> bool {
9955 if explicit {
9956 return true;
9957 }
9958 if std::env::var("WIRE_NO_AUTO_JSON").is_ok() {
9959 return false;
9960 }
9961 use std::io::IsTerminal;
9962 !std::io::stdout().is_terminal()
9963}
9964
9965fn process_alive_pid(pid: u32) -> bool {
9966 crate::platform::process_alive(pid)
9971}
9972
9973fn levenshtein_ci(a: &str, b: &str) -> usize {
9979 let a: Vec<char> = a.to_ascii_lowercase().chars().collect();
9980 let b: Vec<char> = b.to_ascii_lowercase().chars().collect();
9981 let (a, b) = if a.len() < b.len() { (a, b) } else { (b, a) };
9982 let (m, n) = (a.len(), b.len());
9983 if m == 0 {
9984 return n;
9985 }
9986 let mut prev: Vec<usize> = (0..=m).collect();
9987 let mut curr = vec![0usize; m + 1];
9988 for j in 1..=n {
9989 curr[0] = j;
9990 for i in 1..=m {
9991 let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
9992 curr[i] = std::cmp::min(
9993 std::cmp::min(curr[i - 1] + 1, prev[i] + 1),
9994 prev[i - 1] + cost,
9995 );
9996 }
9997 std::mem::swap(&mut prev, &mut curr);
9998 }
9999 prev[m]
10000}
10001
10002pub fn closest_candidates(
10006 needle: &str,
10007 pool: &[String],
10008 max_distance: usize,
10009 max_results: usize,
10010) -> Vec<String> {
10011 let mut scored: Vec<(usize, &String)> = pool
10012 .iter()
10013 .map(|c| (levenshtein_ci(needle, c), c))
10014 .filter(|(d, _)| *d <= max_distance)
10015 .collect();
10016 scored.sort_by_key(|(d, _)| *d);
10017 scored
10018 .into_iter()
10019 .take(max_results)
10020 .map(|(_, c)| c.clone())
10021 .collect()
10022}
10023
10024fn known_local_names() -> Vec<String> {
10029 let mut names: Vec<String> = Vec::new();
10030 if let Ok(trust) = config::read_trust() {
10031 if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
10037 for (handle, agent) in agents {
10038 names.push(handle.clone());
10039 if let Some(did) = agent.get("did").and_then(Value::as_str) {
10040 let ch = crate::character::Character::from_did(did);
10041 names.push(ch.nickname);
10042 }
10043 }
10044 }
10045 }
10046 if let Ok(sessions) = crate::session::list_sessions() {
10047 for s in sessions {
10048 names.push(s.name.clone());
10049 if let Some(h) = &s.handle {
10050 names.push(h.clone());
10051 }
10052 if let Some(ch) = &s.character {
10053 names.push(ch.nickname.clone());
10054 }
10055 }
10056 }
10057 names.sort();
10058 names.dedup();
10059 names
10060}
10061
10062fn deprecation_warn(verb: &str, replacement: &str, json_mode: bool) {
10070 if json_mode {
10071 return;
10072 }
10073 let key = format!("WIRE_DEPRECATION_NAGGED_{}", verb.replace('-', "_"));
10081 if std::env::var(&key).is_ok() {
10082 return;
10083 }
10084 unsafe {
10088 std::env::set_var(&key, "1");
10089 }
10090 eprintln!(
10091 "wire {verb}: DEPRECATED in v0.9 — use `wire {replacement}`. \
10092 Will be removed in v1.0 (target 2026-Q3). \
10093 Suppress: set WIRE_DEPRECATION_NAGGED_{}=1.",
10094 verb.replace('-', "_")
10095 );
10096}
10097
10098#[derive(Clone, Debug, serde::Serialize)]
10102pub struct DoctorCheck {
10103 pub id: String,
10106 pub status: String,
10108 pub detail: String,
10110 #[serde(skip_serializing_if = "Option::is_none")]
10112 pub fix: Option<String>,
10113}
10114
10115impl DoctorCheck {
10116 fn pass(id: &str, detail: impl Into<String>) -> Self {
10117 Self {
10118 id: id.into(),
10119 status: "PASS".into(),
10120 detail: detail.into(),
10121 fix: None,
10122 }
10123 }
10124 fn warn(id: &str, detail: impl Into<String>, fix: impl Into<String>) -> Self {
10125 Self {
10126 id: id.into(),
10127 status: "WARN".into(),
10128 detail: detail.into(),
10129 fix: Some(fix.into()),
10130 }
10131 }
10132 fn fail(id: &str, detail: impl Into<String>, fix: impl Into<String>) -> Self {
10133 Self {
10134 id: id.into(),
10135 status: "FAIL".into(),
10136 detail: detail.into(),
10137 fix: Some(fix.into()),
10138 }
10139 }
10140}
10141
10142fn cmd_doctor(as_json: bool, recent_rejections: usize) -> Result<()> {
10147 let checks: Vec<DoctorCheck> = vec![
10148 check_daemon_health(),
10149 check_daemon_pid_consistency(),
10150 check_relay_reachable(),
10151 check_pair_rejections(recent_rejections),
10152 check_cursor_progress(),
10153 ];
10154
10155 let fails = checks.iter().filter(|c| c.status == "FAIL").count();
10156 let warns = checks.iter().filter(|c| c.status == "WARN").count();
10157
10158 if as_json {
10159 println!(
10160 "{}",
10161 serde_json::to_string(&json!({
10162 "checks": checks,
10163 "fail_count": fails,
10164 "warn_count": warns,
10165 "ok": fails == 0,
10166 }))?
10167 );
10168 } else {
10169 println!("wire doctor — {} checks", checks.len());
10170 for c in &checks {
10171 let bullet = match c.status.as_str() {
10172 "PASS" => "✓",
10173 "WARN" => "!",
10174 "FAIL" => "✗",
10175 _ => "?",
10176 };
10177 println!(" {bullet} [{}] {}: {}", c.status, c.id, c.detail);
10178 if let Some(fix) = &c.fix {
10179 println!(" fix: {fix}");
10180 }
10181 }
10182 println!();
10183 if fails == 0 && warns == 0 {
10184 println!("ALL GREEN");
10185 } else {
10186 println!("{fails} FAIL, {warns} WARN");
10187 }
10188 }
10189
10190 if fails > 0 {
10191 std::process::exit(1);
10192 }
10193 Ok(())
10194}
10195
10196fn check_daemon_health() -> DoctorCheck {
10203 let snap = crate::ensure_up::daemon_liveness();
10209 let pgrep_pids = &snap.pgrep_pids;
10210 let pidfile_pid = snap.pidfile_pid;
10211 let pidfile_alive = snap.pidfile_alive;
10212 let orphan_pids = &snap.orphan_pids;
10213
10214 let fmt_pids = |xs: &[u32]| -> String {
10215 xs.iter()
10216 .map(|p| p.to_string())
10217 .collect::<Vec<_>>()
10218 .join(", ")
10219 };
10220
10221 match (pgrep_pids.len(), pidfile_alive, orphan_pids.is_empty()) {
10222 (0, _, _) => DoctorCheck::fail(
10223 "daemon",
10224 "no `wire daemon` process running — nothing pulling inbox or pushing outbox",
10225 "`wire daemon &` to start, or re-run `wire up <handle>@<relay>` to bootstrap",
10226 ),
10227 (1, true, true) => DoctorCheck::pass(
10229 "daemon",
10230 format!(
10231 "one daemon running (pid {}, matches pidfile)",
10232 pgrep_pids[0]
10233 ),
10234 ),
10235 (n, true, false) => DoctorCheck::fail(
10237 "daemon",
10238 format!(
10239 "{n} `wire daemon` processes running (pids: {}); pidfile claims pid {} but pgrep also sees orphan(s): {}. \
10240 The orphans race the relay cursor — they advance past events your current binary can't process. \
10241 (Issue #2 exact class.)",
10242 fmt_pids(pgrep_pids),
10243 pidfile_pid.unwrap(),
10244 fmt_pids(orphan_pids),
10245 ),
10246 "`wire upgrade` kills all orphans and spawns a fresh daemon with a clean pidfile",
10247 ),
10248 (n, false, _) => DoctorCheck::fail(
10250 "daemon",
10251 format!(
10252 "{n} `wire daemon` process(es) running (pids: {}) but pidfile {} — \
10253 every running daemon is an orphan, advancing the cursor without coordinating with the current CLI. \
10254 (Issue #2 exact class: doctor previously PASSed this state while `wire status` said DOWN.)",
10255 fmt_pids(pgrep_pids),
10256 match pidfile_pid {
10257 Some(p) => format!("claims pid {p} which is dead"),
10258 None => "is missing".to_string(),
10259 },
10260 ),
10261 "`wire upgrade` to kill the orphan(s) and spawn a fresh daemon",
10262 ),
10263 (n, true, true) => DoctorCheck::warn(
10265 "daemon",
10266 format!(
10267 "{n} `wire daemon` processes running (pids: {}). Multiple daemons race the relay cursor.",
10268 fmt_pids(pgrep_pids)
10269 ),
10270 "kill all-but-one: `pkill -f \"wire daemon\"; wire daemon &`",
10271 ),
10272 }
10273}
10274
10275fn check_daemon_pid_consistency() -> DoctorCheck {
10287 let snap = crate::ensure_up::daemon_liveness();
10288 match &snap.record {
10289 crate::ensure_up::PidRecord::Missing => DoctorCheck::pass(
10290 "daemon_pid_consistency",
10291 "no daemon.pid yet — fresh box or daemon never started",
10292 ),
10293 crate::ensure_up::PidRecord::Corrupt(reason) => DoctorCheck::warn(
10294 "daemon_pid_consistency",
10295 format!("daemon.pid is corrupt: {reason}"),
10296 "delete state/wire/daemon.pid; next `wire daemon &` will rewrite",
10297 ),
10298 crate::ensure_up::PidRecord::LegacyInt(pid) => {
10299 let pid = *pid;
10302 if !crate::ensure_up::pid_is_alive(pid) {
10303 return DoctorCheck::warn(
10304 "daemon_pid_consistency",
10305 format!(
10306 "daemon.pid (legacy-int) points at pid {pid} which is not running. \
10307 Stale pidfile from a crashed pre-0.5.11 daemon. \
10308 (Issue #2: this surface used to PASS while `wire status` said DOWN.)"
10309 ),
10310 "`wire upgrade` (kills any orphan + spawns a fresh daemon with JSON pidfile)",
10311 );
10312 }
10313 DoctorCheck::warn(
10314 "daemon_pid_consistency",
10315 format!(
10316 "daemon.pid is legacy-int form (pid={pid}, no version/bin_path metadata). \
10317 Daemon was started by a pre-0.5.11 binary."
10318 ),
10319 "run `wire upgrade` to kill the old daemon and start a fresh one with the JSON pidfile",
10320 )
10321 }
10322 crate::ensure_up::PidRecord::Json(d) => {
10323 if !snap.pidfile_alive {
10327 return DoctorCheck::warn(
10328 "daemon_pid_consistency",
10329 format!(
10330 "daemon.pid records pid {pid} (v{version}) but that process is not running — \
10331 pidfile is stale. `wire status` will report DOWN, but pre-v0.5.19 doctor \
10332 silently PASSed this state and ignored any live orphan daemons (#2 root cause).",
10333 pid = d.pid,
10334 version = d.version,
10335 ),
10336 "`wire upgrade` to clean up the stale pidfile + spawn a fresh daemon \
10337 (kills any orphan daemon advancing the cursor without coordination)",
10338 );
10339 }
10340 let mut issues: Vec<String> = Vec::new();
10341 if d.schema != crate::ensure_up::DAEMON_PID_SCHEMA {
10342 issues.push(format!(
10343 "schema={} (expected {})",
10344 d.schema,
10345 crate::ensure_up::DAEMON_PID_SCHEMA
10346 ));
10347 }
10348 let cli_version = env!("CARGO_PKG_VERSION");
10349 if d.version != cli_version {
10350 issues.push(format!("version daemon={} cli={cli_version}", d.version));
10351 }
10352 if !std::path::Path::new(&d.bin_path).exists() {
10353 issues.push(format!("bin_path {} missing on disk", d.bin_path));
10354 }
10355 if let Ok(card) = config::read_agent_card()
10357 && let Some(current_did) = card.get("did").and_then(Value::as_str)
10358 && let Some(recorded_did) = &d.did
10359 && recorded_did != current_did
10360 {
10361 issues.push(format!(
10362 "did daemon={recorded_did} config={current_did} — identity drift"
10363 ));
10364 }
10365 if let Ok(state) = config::read_relay_state()
10366 && let Some(current_relay) = state
10367 .get("self")
10368 .and_then(|s| s.get("relay_url"))
10369 .and_then(Value::as_str)
10370 && let Some(recorded_relay) = &d.relay_url
10371 && recorded_relay != current_relay
10372 {
10373 issues.push(format!(
10374 "relay_url daemon={recorded_relay} config={current_relay} — relay-migration drift"
10375 ));
10376 }
10377 if issues.is_empty() {
10378 DoctorCheck::pass(
10379 "daemon_pid_consistency",
10380 format!(
10381 "daemon v{} bound to {} as {}",
10382 d.version,
10383 d.relay_url.as_deref().unwrap_or("?"),
10384 d.did.as_deref().unwrap_or("?")
10385 ),
10386 )
10387 } else {
10388 DoctorCheck::warn(
10389 "daemon_pid_consistency",
10390 format!("daemon pidfile drift: {}", issues.join("; ")),
10391 "`wire upgrade` to atomically restart daemon with current config".to_string(),
10392 )
10393 }
10394 }
10395 }
10396}
10397
10398fn check_relay_reachable() -> DoctorCheck {
10400 let state = match config::read_relay_state() {
10401 Ok(s) => s,
10402 Err(e) => {
10403 return DoctorCheck::fail(
10404 "relay",
10405 format!("could not read relay state: {e}"),
10406 "run `wire up <handle>@<relay>` to bootstrap",
10407 );
10408 }
10409 };
10410 let url = state
10411 .get("self")
10412 .and_then(|s| s.get("relay_url"))
10413 .and_then(Value::as_str)
10414 .unwrap_or("");
10415 if url.is_empty() {
10416 return DoctorCheck::warn(
10417 "relay",
10418 "no relay bound — wire send/pull will not work",
10419 "run `wire bind-relay <url>` or `wire up <handle>@<relay>`",
10420 );
10421 }
10422 let client = crate::relay_client::RelayClient::new(url);
10423 match client.check_healthz() {
10424 Ok(()) => DoctorCheck::pass("relay", format!("{url} healthz=200")),
10425 Err(e) => DoctorCheck::fail(
10426 "relay",
10427 format!("{url} unreachable: {e}"),
10428 format!("network reachable to {url}? relay running? check `curl {url}/healthz`"),
10429 ),
10430 }
10431}
10432
10433fn check_pair_rejections(recent_n: usize) -> DoctorCheck {
10437 let path = match config::state_dir() {
10438 Ok(d) => d.join("pair-rejected.jsonl"),
10439 Err(e) => {
10440 return DoctorCheck::warn(
10441 "pair_rejections",
10442 format!("could not resolve state dir: {e}"),
10443 "set WIRE_HOME or fix XDG_STATE_HOME",
10444 );
10445 }
10446 };
10447 if !path.exists() {
10448 return DoctorCheck::pass(
10449 "pair_rejections",
10450 "no pair-rejected.jsonl — no recorded pair failures",
10451 );
10452 }
10453 let body = match std::fs::read_to_string(&path) {
10454 Ok(b) => b,
10455 Err(e) => {
10456 return DoctorCheck::warn(
10457 "pair_rejections",
10458 format!("could not read {path:?}: {e}"),
10459 "check file permissions",
10460 );
10461 }
10462 };
10463 let lines: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
10464 if lines.is_empty() {
10465 return DoctorCheck::pass("pair_rejections", "pair-rejected.jsonl present but empty");
10466 }
10467 let total = lines.len();
10468 let recent: Vec<&str> = lines.iter().rev().take(recent_n).rev().copied().collect();
10469 let mut summary: Vec<String> = Vec::new();
10470 for line in &recent {
10471 if let Ok(rec) = serde_json::from_str::<Value>(line) {
10472 let peer = rec.get("peer").and_then(Value::as_str).unwrap_or("?");
10473 let code = rec.get("code").and_then(Value::as_str).unwrap_or("?");
10474 summary.push(format!("{peer}/{code}"));
10475 }
10476 }
10477 DoctorCheck::warn(
10478 "pair_rejections",
10479 format!(
10480 "{total} pair failures recorded. recent: [{}]",
10481 summary.join(", ")
10482 ),
10483 format!(
10484 "inspect {path:?} for full details. Each entry is a pair-flow error that previously silently dropped — re-run `wire pair <handle>@<relay>` to retry."
10485 ),
10486 )
10487}
10488
10489fn check_cursor_progress() -> DoctorCheck {
10494 let state = match config::read_relay_state() {
10495 Ok(s) => s,
10496 Err(e) => {
10497 return DoctorCheck::warn(
10498 "cursor",
10499 format!("could not read relay state: {e}"),
10500 "check ~/Library/Application Support/wire/relay.json",
10501 );
10502 }
10503 };
10504 let cursor = state
10505 .get("self")
10506 .and_then(|s| s.get("last_pulled_event_id"))
10507 .and_then(Value::as_str)
10508 .map(|s| s.chars().take(16).collect::<String>())
10509 .unwrap_or_else(|| "<none>".to_string());
10510 DoctorCheck::pass(
10511 "cursor",
10512 format!(
10513 "current cursor: {cursor}. P0.1 cursor blocking is active — see `wire pull --json` for cursor_blocked / rejected[].blocks_cursor entries."
10514 ),
10515 )
10516}
10517
10518#[cfg(test)]
10519mod doctor_tests {
10520 use super::*;
10521
10522 #[test]
10523 fn doctor_check_constructors_set_status_correctly() {
10524 let p = DoctorCheck::pass("x", "ok");
10529 assert_eq!(p.status, "PASS");
10530 assert_eq!(p.fix, None);
10531
10532 let w = DoctorCheck::warn("x", "watch out", "do this");
10533 assert_eq!(w.status, "WARN");
10534 assert_eq!(w.fix, Some("do this".to_string()));
10535
10536 let f = DoctorCheck::fail("x", "broken", "fix it");
10537 assert_eq!(f.status, "FAIL");
10538 assert_eq!(f.fix, Some("fix it".to_string()));
10539 }
10540
10541 #[test]
10542 fn check_pair_rejections_no_file_is_pass() {
10543 config::test_support::with_temp_home(|| {
10546 config::ensure_dirs().unwrap();
10547 let c = check_pair_rejections(5);
10548 assert_eq!(c.status, "PASS", "no file should be PASS, got {c:?}");
10549 });
10550 }
10551
10552 #[test]
10553 fn check_pair_rejections_with_entries_warns() {
10554 config::test_support::with_temp_home(|| {
10558 config::ensure_dirs().unwrap();
10559 crate::pair_invite::record_pair_rejection(
10560 "willard",
10561 "pair_drop_ack_send_failed",
10562 "POST 502",
10563 );
10564 let c = check_pair_rejections(5);
10565 assert_eq!(c.status, "WARN");
10566 assert!(c.detail.contains("1 pair failures"));
10567 assert!(c.detail.contains("willard/pair_drop_ack_send_failed"));
10568 });
10569 }
10570}
10571
10572fn cmd_up(
10584 relay_arg: Option<&str>,
10585 name: Option<&str>,
10586 with_local: Option<&str>,
10587 no_local: bool,
10588 as_json: bool,
10589) -> Result<()> {
10590 let relay_url = match relay_arg {
10594 Some(r) => {
10595 let r = r.trim_start_matches('@');
10596 if r.starts_with("http://") || r.starts_with("https://") {
10597 r.to_string()
10598 } else {
10599 format!("https://{r}")
10600 }
10601 }
10602 None => crate::pair_invite::DEFAULT_RELAY.to_string(),
10603 };
10604
10605 let mut report: Vec<(String, String)> = Vec::new();
10606 let mut step = |stage: &str, detail: String| {
10607 report.push((stage.to_string(), detail.clone()));
10608 if !as_json {
10609 eprintln!("wire up: {stage} — {detail}");
10610 }
10611 };
10612
10613 if config::is_initialized()? {
10616 step("init", "already initialized".to_string());
10617 } else {
10618 cmd_init(
10619 None,
10620 name,
10621 Some(&relay_url),
10622 false,
10623 false,
10624 )?;
10625 step("init", format!("created identity bound to {relay_url}"));
10626 }
10627
10628 let canonical = {
10630 let card = config::read_agent_card()?;
10631 let did = card.get("did").and_then(Value::as_str).unwrap_or("");
10632 crate::agent_card::display_handle_from_did(did).to_string()
10633 };
10634 step("identity", format!("persona is `{canonical}`"));
10635
10636 let relay_state = config::read_relay_state()?;
10640 let bound_relay = relay_state
10641 .get("self")
10642 .and_then(|s| s.get("relay_url"))
10643 .and_then(Value::as_str)
10644 .unwrap_or("")
10645 .to_string();
10646 if bound_relay.is_empty() {
10647 cmd_bind_relay(
10651 &relay_url, None, false, false, false,
10653 )?;
10654 step("bind-relay", format!("bound to {relay_url}"));
10655 } else if bound_relay != relay_url {
10656 step(
10657 "bind-relay",
10658 format!(
10659 "WARNING: identity bound to {bound_relay} but you specified {relay_url}. \
10660 Keeping existing binding. Run `wire bind-relay {relay_url}` to switch."
10661 ),
10662 );
10663 } else {
10664 step("bind-relay", format!("already bound to {bound_relay}"));
10665 }
10666
10667 match cmd_claim(
10670 &canonical,
10671 Some(&relay_url),
10672 None,
10673 false,
10674 false,
10675 ) {
10676 Ok(()) => step(
10677 "claim",
10678 format!("{canonical}@{} claimed", strip_proto(&relay_url)),
10679 ),
10680 Err(e) => step(
10681 "claim",
10682 format!("WARNING: claim failed: {e}. You can retry `wire claim {canonical}`."),
10683 ),
10684 }
10685
10686 if no_local {
10691 step("local-slot", "skipped (--no-local)".to_string());
10692 } else {
10693 let local_url = with_local
10694 .unwrap_or("http://127.0.0.1:8771")
10695 .trim_end_matches('/');
10696 let already_local = crate::endpoints::self_endpoints(
10697 &config::read_relay_state().unwrap_or_else(|_| json!({})),
10698 )
10699 .iter()
10700 .any(|e| e.relay_url == local_url);
10701 if relay_url.trim_end_matches('/') == local_url || already_local {
10702 step("local-slot", "already covered".to_string());
10703 } else if crate::relay_client::RelayClient::new(local_url)
10704 .check_healthz()
10705 .is_ok()
10706 {
10707 match cmd_bind_relay(
10708 local_url,
10709 Some("local"),
10710 false,
10711 false,
10712 false,
10713 ) {
10714 Ok(()) => step(
10715 "local-slot",
10716 format!("dual-bound local relay {local_url} for sister routing"),
10717 ),
10718 Err(e) => step("local-slot", format!("skipped local relay: {e}")),
10719 }
10720 } else {
10721 step(
10722 "local-slot",
10723 format!(
10724 "no local relay reachable at {local_url} — federation only \
10725 (sisters resolve via session-list)"
10726 ),
10727 );
10728 }
10729 }
10730
10731 match crate::ensure_up::ensure_daemon_running() {
10733 Ok(true) => step("daemon", "started fresh background daemon".to_string()),
10734 Ok(false) => step("daemon", "already running".to_string()),
10735 Err(e) => step(
10736 "daemon",
10737 format!("WARNING: could not start daemon: {e}. Run `wire daemon &` manually."),
10738 ),
10739 }
10740
10741 let summary =
10743 "ready. `wire pair <peer>@<relay>` to pair, `wire send <peer> \"<msg>\"` to send, \
10744 `wire monitor` to watch incoming events."
10745 .to_string();
10746 step("ready", summary.clone());
10747
10748 if as_json {
10749 let steps_json: Vec<_> = report
10750 .iter()
10751 .map(|(k, v)| json!({"stage": k, "detail": v}))
10752 .collect();
10753 println!(
10754 "{}",
10755 serde_json::to_string(&json!({
10756 "nick": canonical,
10757 "relay": relay_url,
10758 "steps": steps_json,
10759 }))?
10760 );
10761 }
10762 Ok(())
10763}
10764
10765fn strip_proto(url: &str) -> String {
10767 url.trim_start_matches("https://")
10768 .trim_start_matches("http://")
10769 .to_string()
10770}
10771
10772fn cmd_pair_megacommand(
10786 handle_arg: &str,
10787 relay_override: Option<&str>,
10788 timeout_secs: u64,
10789 _as_json: bool,
10790) -> Result<()> {
10791 let parsed = crate::pair_profile::parse_handle(handle_arg)?;
10792 let peer_handle = parsed.nick.clone();
10793
10794 eprintln!("wire pair: resolving {handle_arg}...");
10795 cmd_add(
10796 handle_arg,
10797 relay_override,
10798 false,
10799 false,
10800 )?;
10801
10802 eprintln!(
10803 "wire pair: intro delivered. waiting up to {timeout_secs}s for {peer_handle} \
10804 to ack (their daemon must be running + pulling)..."
10805 );
10806
10807 let _ = run_sync_pull();
10811
10812 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
10813 let poll_interval = std::time::Duration::from_millis(500);
10814
10815 loop {
10816 let _ = run_sync_pull();
10818 let relay_state = config::read_relay_state()?;
10819 let peer_entry = relay_state
10820 .get("peers")
10821 .and_then(|p| p.get(&peer_handle))
10822 .cloned();
10823 let token = peer_entry
10824 .as_ref()
10825 .and_then(|e| e.get("slot_token"))
10826 .and_then(Value::as_str)
10827 .unwrap_or("");
10828
10829 if !token.is_empty() {
10830 let trust = config::read_trust()?;
10832 let pinned_in_trust = trust
10833 .get("agents")
10834 .and_then(|a| a.get(&peer_handle))
10835 .is_some();
10836 println!(
10837 "wire pair: paired with {peer_handle}.\n trust: {} bilateral: yes (slot_token recorded)\n next: `wire send {peer_handle} \"<msg>\"`",
10838 if pinned_in_trust {
10839 "VERIFIED"
10840 } else {
10841 "MISSING (bug)"
10842 }
10843 );
10844 return Ok(());
10845 }
10846
10847 if std::time::Instant::now() >= deadline {
10848 bail!(
10855 "wire pair: timed out after {timeout_secs}s. \
10856 peer {peer_handle} never sent pair_drop_ack. \
10857 likely causes: (a) their daemon is down — ask them to run \
10858 `wire status` and `wire daemon &`; (b) their binary is older \
10859 than 0.5.x and doesn't understand pair_drop events — ask \
10860 them to `wire upgrade`; (c) network / relay blip — re-run \
10861 `wire pair {handle_arg}` to retry."
10862 );
10863 }
10864
10865 std::thread::sleep(poll_interval);
10866 }
10867}
10868
10869fn cmd_claim(
10870 nick: &str,
10871 relay_override: Option<&str>,
10872 public_url: Option<&str>,
10873 hidden: bool,
10874 as_json: bool,
10875) -> Result<()> {
10876 let (_did, relay_url, slot_id, slot_token) =
10879 crate::pair_invite::ensure_self_with_relay(relay_override)?;
10880 let card = config::read_agent_card()?;
10881
10882 let did = card.get("did").and_then(Value::as_str).unwrap_or_default();
10891 let canonical = crate::agent_card::display_handle_from_did(did).to_string();
10892 if !canonical.is_empty() && nick != canonical && !as_json {
10893 eprintln!(
10894 "wire claim: typed `{nick}` ignored — one-name rule. Claiming your persona `{canonical}`."
10895 );
10896 }
10897 let nick = if canonical.is_empty() {
10898 nick
10899 } else {
10900 canonical.as_str()
10901 };
10902 if !crate::pair_profile::is_valid_nick(nick) {
10903 bail!(
10904 "phyllis: {nick:?} won't fit in the books — handles need 2-32 chars, lowercase [a-z0-9_-], not on the reserved list"
10905 );
10906 }
10907
10908 let client = crate::relay_client::RelayClient::new(&relay_url);
10909 let discoverable = if hidden { Some(false) } else { None };
10913 let resp =
10914 client.handle_claim_v2(nick, &slot_id, &slot_token, public_url, &card, discoverable)?;
10915
10916 if as_json {
10917 println!(
10918 "{}",
10919 serde_json::to_string(&json!({
10920 "nick": nick,
10921 "relay": relay_url,
10922 "response": resp,
10923 }))?
10924 );
10925 } else {
10926 let domain = public_url
10930 .unwrap_or(&relay_url)
10931 .trim_start_matches("https://")
10932 .trim_start_matches("http://")
10933 .trim_end_matches('/')
10934 .split('/')
10935 .next()
10936 .unwrap_or("<this-relay-domain>")
10937 .to_string();
10938 println!("claimed {nick} on {relay_url} — others can reach you at: {nick}@{domain}");
10939 println!("verify with: wire whois {nick}@{domain}");
10940 }
10941 Ok(())
10942}
10943
10944fn cmd_profile(action: ProfileAction) -> Result<()> {
10945 match action {
10946 ProfileAction::Set { field, value, json } => {
10947 let parsed: Value =
10951 serde_json::from_str(&value).unwrap_or(Value::String(value.clone()));
10952 let new_profile = crate::pair_profile::write_profile_field(&field, parsed)?;
10953 if json {
10954 println!(
10955 "{}",
10956 serde_json::to_string(&json!({
10957 "field": field,
10958 "profile": new_profile,
10959 }))?
10960 );
10961 } else {
10962 println!("profile.{field} set");
10963 }
10964 }
10965 ProfileAction::Get { json } => return cmd_whois(None, json, None),
10966 ProfileAction::Clear { field, json } => {
10967 let new_profile = crate::pair_profile::write_profile_field(&field, Value::Null)?;
10968 if json {
10969 println!(
10970 "{}",
10971 serde_json::to_string(&json!({
10972 "field": field,
10973 "cleared": true,
10974 "profile": new_profile,
10975 }))?
10976 );
10977 } else {
10978 println!("profile.{field} cleared");
10979 }
10980 }
10981 }
10982 Ok(())
10983}
10984
10985fn cmd_setup(apply: bool) -> Result<()> {
10988 use std::path::PathBuf;
10989
10990 let entry = json!({"command": "wire", "args": ["mcp"]});
10991 let entry_pretty = serde_json::to_string_pretty(&json!({"wire": &entry}))?;
10992
10993 let mut targets: Vec<(&str, PathBuf)> = Vec::new();
10996 if let Some(home) = dirs::home_dir() {
10997 targets.push(("Claude Code", home.join(".claude.json")));
11000 targets.push(("Claude Code (alt)", home.join(".config/claude/mcp.json")));
11002 #[cfg(target_os = "macos")]
11004 targets.push((
11005 "Claude Desktop (macOS)",
11006 home.join("Library/Application Support/Claude/claude_desktop_config.json"),
11007 ));
11008 #[cfg(target_os = "windows")]
11010 if let Ok(appdata) = std::env::var("APPDATA") {
11011 targets.push((
11012 "Claude Desktop (Windows)",
11013 PathBuf::from(appdata).join("Claude/claude_desktop_config.json"),
11014 ));
11015 }
11016 targets.push(("Cursor", home.join(".cursor/mcp.json")));
11018 }
11019 targets.push(("project-local (.mcp.json)", PathBuf::from(".mcp.json")));
11021
11022 println!("wire setup\n");
11023 println!("MCP server snippet (add this to your client's mcpServers):");
11024 println!();
11025 println!("{entry_pretty}");
11026 println!();
11027
11028 if !apply {
11029 println!("Probable MCP host config locations on this machine:");
11030 for (name, path) in &targets {
11031 let marker = if path.exists() {
11032 "✓ found"
11033 } else {
11034 " (would create)"
11035 };
11036 println!(" {marker:14} {name}: {}", path.display());
11037 }
11038 println!();
11039 println!("Run `wire setup --apply` to merge wire into each config above.");
11040 println!(
11041 "Existing entries with a different command keep yours unchanged unless wire's exact entry is missing."
11042 );
11043 return Ok(());
11044 }
11045
11046 let mut modified: Vec<String> = Vec::new();
11047 let mut skipped: Vec<String> = Vec::new();
11048 for (name, path) in &targets {
11049 match upsert_mcp_entry(path, "wire", &entry) {
11050 Ok(true) => modified.push(format!("✓ {name} ({})", path.display())),
11051 Ok(false) => skipped.push(format!(" {name} ({}): already configured", path.display())),
11052 Err(e) => skipped.push(format!("✗ {name} ({}): {e}", path.display())),
11053 }
11054 }
11055 if !modified.is_empty() {
11056 println!("Modified:");
11057 for line in &modified {
11058 println!(" {line}");
11059 }
11060 println!();
11061 println!("Restart the app(s) above to load wire MCP.");
11062 }
11063 if !skipped.is_empty() {
11064 println!();
11065 println!("Skipped:");
11066 for line in &skipped {
11067 println!(" {line}");
11068 }
11069 }
11070 Ok(())
11071}
11072
11073fn upsert_mcp_entry(path: &std::path::Path, server_name: &str, entry: &Value) -> Result<bool> {
11076 let mut cfg: Value = if path.exists() {
11077 let body = std::fs::read_to_string(path).context("reading config")?;
11078 serde_json::from_str(&body).unwrap_or_else(|_| json!({}))
11079 } else {
11080 json!({})
11081 };
11082 if !cfg.is_object() {
11083 cfg = json!({});
11084 }
11085 let root = cfg.as_object_mut().unwrap();
11086 let servers = root
11087 .entry("mcpServers".to_string())
11088 .or_insert_with(|| json!({}));
11089 if !servers.is_object() {
11090 *servers = json!({});
11091 }
11092 let map = servers.as_object_mut().unwrap();
11093 if map.get(server_name) == Some(entry) {
11094 return Ok(false);
11095 }
11096 map.insert(server_name.to_string(), entry.clone());
11097 if let Some(parent) = path.parent()
11098 && !parent.as_os_str().is_empty()
11099 {
11100 std::fs::create_dir_all(parent).context("creating parent dir")?;
11101 }
11102 let out = serde_json::to_string_pretty(&cfg)? + "\n";
11103 std::fs::write(path, out).context("writing config")?;
11104 Ok(true)
11105}
11106
11107#[allow(clippy::too_many_arguments)]
11110fn cmd_reactor(
11111 on_event: &str,
11112 peer_filter: Option<&str>,
11113 kind_filter: Option<&str>,
11114 verified_only: bool,
11115 interval_secs: u64,
11116 once: bool,
11117 dry_run: bool,
11118 max_per_minute: u32,
11119 max_chain_depth: u32,
11120) -> Result<()> {
11121 use crate::inbox_watch::{InboxEvent, InboxWatcher};
11122 use std::collections::{HashMap, HashSet, VecDeque};
11123 use std::io::Write;
11124 use std::process::{Command, Stdio};
11125 use std::time::{Duration, Instant};
11126
11127 let cursor_path = config::state_dir()?.join("reactor.cursor");
11128 let emitted_path = config::state_dir()?.join("reactor-emitted.log");
11137 let mut emitted_ids: HashSet<String> = HashSet::new();
11138 if emitted_path.exists()
11139 && let Ok(body) = std::fs::read_to_string(&emitted_path)
11140 {
11141 for line in body.lines() {
11142 let t = line.trim();
11143 if !t.is_empty() {
11144 emitted_ids.insert(t.to_string());
11145 }
11146 }
11147 }
11148 let outbox_dir = config::outbox_dir()?;
11150 let mut outbox_cursors: HashMap<String, u64> = HashMap::new();
11153
11154 let mut watcher = InboxWatcher::from_cursor_file(&cursor_path)?;
11155
11156 let kind_num: Option<u32> = match kind_filter {
11157 Some(k) => Some(parse_kind(k)?),
11158 None => None,
11159 };
11160
11161 let mut peer_dispatch_log: HashMap<String, VecDeque<Instant>> = HashMap::new();
11163
11164 let dispatch = |ev: &InboxEvent,
11165 peer_dispatch_log: &mut HashMap<String, VecDeque<Instant>>,
11166 emitted_ids: &HashSet<String>|
11167 -> Result<bool> {
11168 if let Some(p) = peer_filter
11169 && ev.peer != p
11170 {
11171 return Ok(false);
11172 }
11173 if verified_only && !ev.verified {
11174 return Ok(false);
11175 }
11176 if let Some(want) = kind_num {
11177 let ev_kind = ev.raw.get("kind").and_then(Value::as_u64).map(|n| n as u32);
11178 if ev_kind != Some(want) {
11179 return Ok(false);
11180 }
11181 }
11182
11183 if max_chain_depth > 0 {
11187 let body_str = match &ev.raw["body"] {
11188 Value::String(s) => s.clone(),
11189 other => serde_json::to_string(other).unwrap_or_default(),
11190 };
11191 if let Some(referenced) = parse_re_marker(&body_str) {
11192 let matched = emitted_ids.contains(&referenced)
11195 || emitted_ids.iter().any(|full| full.starts_with(&referenced));
11196 if matched {
11197 eprintln!(
11198 "wire reactor: skip {} from {} — chain-depth (reply to our re:{})",
11199 ev.event_id, ev.peer, referenced
11200 );
11201 return Ok(false);
11202 }
11203 }
11204 }
11205
11206 if max_per_minute > 0 {
11208 let now = Instant::now();
11209 let win = peer_dispatch_log.entry(ev.peer.clone()).or_default();
11210 while let Some(&front) = win.front() {
11211 if now.duration_since(front) > Duration::from_secs(60) {
11212 win.pop_front();
11213 } else {
11214 break;
11215 }
11216 }
11217 if win.len() as u32 >= max_per_minute {
11218 eprintln!(
11219 "wire reactor: skip {} from {} — rate-limit ({}/min reached)",
11220 ev.event_id, ev.peer, max_per_minute
11221 );
11222 return Ok(false);
11223 }
11224 win.push_back(now);
11225 }
11226
11227 if dry_run {
11228 println!("{}", serde_json::to_string(&ev.raw)?);
11229 return Ok(true);
11230 }
11231
11232 let mut child = Command::new("sh")
11233 .arg("-c")
11234 .arg(on_event)
11235 .stdin(Stdio::piped())
11236 .stdout(Stdio::inherit())
11237 .stderr(Stdio::inherit())
11238 .env("WIRE_EVENT_PEER", &ev.peer)
11239 .env("WIRE_EVENT_ID", &ev.event_id)
11240 .env("WIRE_EVENT_KIND", &ev.kind)
11241 .spawn()
11242 .with_context(|| format!("spawning reactor handler: {on_event}"))?;
11243 if let Some(mut stdin) = child.stdin.take() {
11244 let body = serde_json::to_vec(&ev.raw)?;
11245 let _ = stdin.write_all(&body);
11246 let _ = stdin.write_all(b"\n");
11247 }
11248 std::mem::drop(child);
11249 Ok(true)
11250 };
11251
11252 let scan_outbox = |emitted_ids: &mut HashSet<String>,
11254 outbox_cursors: &mut HashMap<String, u64>|
11255 -> Result<usize> {
11256 if !outbox_dir.exists() {
11257 return Ok(0);
11258 }
11259 let mut added = 0;
11260 let mut new_ids: Vec<String> = Vec::new();
11261 for entry in std::fs::read_dir(&outbox_dir)?.flatten() {
11262 let path = entry.path();
11263 if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
11264 continue;
11265 }
11266 let peer = match path.file_stem().and_then(|s| s.to_str()) {
11267 Some(s) => s.to_string(),
11268 None => continue,
11269 };
11270 let cur_len = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
11271 let start = *outbox_cursors.get(&peer).unwrap_or(&0);
11272 if cur_len <= start {
11273 outbox_cursors.insert(peer, start);
11274 continue;
11275 }
11276 let body = std::fs::read_to_string(&path).unwrap_or_default();
11277 let tail = &body[start as usize..];
11278 for line in tail.lines() {
11279 if let Ok(v) = serde_json::from_str::<Value>(line)
11280 && let Some(eid) = v.get("event_id").and_then(Value::as_str)
11281 && emitted_ids.insert(eid.to_string())
11282 {
11283 new_ids.push(eid.to_string());
11284 added += 1;
11285 }
11286 }
11287 outbox_cursors.insert(peer, cur_len);
11288 }
11289 if !new_ids.is_empty() {
11290 let mut all: Vec<String> = emitted_ids.iter().cloned().collect();
11292 if all.len() > 500 {
11293 all.sort();
11294 let drop_n = all.len() - 500;
11295 let dropped: HashSet<String> = all.iter().take(drop_n).cloned().collect();
11296 emitted_ids.retain(|x| !dropped.contains(x));
11297 all = emitted_ids.iter().cloned().collect();
11298 }
11299 let _ = std::fs::write(&emitted_path, all.join("\n") + "\n");
11300 }
11301 Ok(added)
11302 };
11303
11304 let sweep = |watcher: &mut InboxWatcher,
11305 emitted_ids: &mut HashSet<String>,
11306 outbox_cursors: &mut HashMap<String, u64>,
11307 peer_dispatch_log: &mut HashMap<String, VecDeque<Instant>>|
11308 -> Result<usize> {
11309 let _ = scan_outbox(emitted_ids, outbox_cursors);
11311
11312 let events = watcher.poll()?;
11313 let mut fired = 0usize;
11314 for ev in &events {
11315 match dispatch(ev, peer_dispatch_log, emitted_ids) {
11316 Ok(true) => fired += 1,
11317 Ok(false) => {}
11318 Err(e) => eprintln!("wire reactor: handler error for {}: {e}", ev.event_id),
11319 }
11320 }
11321 watcher.save_cursors(&cursor_path)?;
11322 Ok(fired)
11323 };
11324
11325 if once {
11326 sweep(
11327 &mut watcher,
11328 &mut emitted_ids,
11329 &mut outbox_cursors,
11330 &mut peer_dispatch_log,
11331 )?;
11332 return Ok(());
11333 }
11334 let interval = std::time::Duration::from_secs(interval_secs.max(1));
11335 loop {
11336 if let Err(e) = sweep(
11337 &mut watcher,
11338 &mut emitted_ids,
11339 &mut outbox_cursors,
11340 &mut peer_dispatch_log,
11341 ) {
11342 eprintln!("wire reactor: sweep error: {e}");
11343 }
11344 std::thread::sleep(interval);
11345 }
11346}
11347
11348fn parse_re_marker(body: &str) -> Option<String> {
11351 let needle = "(re:";
11352 let i = body.find(needle)?;
11353 let rest = &body[i + needle.len()..];
11354 let end = rest.find(')')?;
11355 let id = rest[..end].trim().to_string();
11356 if id.is_empty() {
11357 return None;
11358 }
11359 Some(id)
11360}
11361
11362fn cmd_notify(
11365 interval_secs: u64,
11366 peer_filter: Option<&str>,
11367 once: bool,
11368 as_json: bool,
11369) -> Result<()> {
11370 use crate::inbox_watch::InboxWatcher;
11371 let cursor_path = config::state_dir()?.join("notify.cursor");
11372 let mut watcher = InboxWatcher::from_cursor_file(&cursor_path)?;
11373
11374 let sweep = |watcher: &mut InboxWatcher| -> Result<()> {
11375 let events = watcher.poll()?;
11376 for ev in events {
11377 if let Some(p) = peer_filter
11378 && ev.peer != p
11379 {
11380 continue;
11381 }
11382 if as_json {
11383 println!("{}", serde_json::to_string(&ev)?);
11384 } else {
11385 os_notify_inbox_event(&ev);
11386 }
11387 }
11388 watcher.save_cursors(&cursor_path)?;
11389 Ok(())
11390 };
11391
11392 if once {
11393 return sweep(&mut watcher);
11394 }
11395
11396 let interval = std::time::Duration::from_secs(interval_secs.max(1));
11397 loop {
11398 if let Err(e) = sweep(&mut watcher) {
11399 eprintln!("wire notify: sweep error: {e}");
11400 }
11401 std::thread::sleep(interval);
11402 }
11403}
11404
11405fn os_notify_inbox_event(ev: &crate::inbox_watch::InboxEvent) {
11406 let who = persona_label(&ev.peer);
11407 let title = if ev.verified {
11408 format!("wire ← {who}")
11409 } else {
11410 format!("wire ← {who} (UNVERIFIED)")
11411 };
11412 let body = format!("{}: {}", ev.kind, ev.body_preview);
11413 crate::os_notify::toast(&title, &body);
11414}
11415
11416#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
11417fn os_toast(title: &str, body: &str) {
11418 eprintln!("[wire notify] {title}\n {body}");
11419}
11420
11421