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 #[command(subcommand)]
617 Group(GroupCommand),
618 Setup {
623 #[arg(long)]
625 apply: bool,
626 #[arg(long)]
632 statusline: bool,
633 #[arg(long)]
636 remove: bool,
637 },
638 Whois {
642 handle: Option<String>,
644 #[arg(long)]
645 json: bool,
646 #[arg(long)]
649 relay: Option<String>,
650 },
651 Add {
657 handle: String,
660 #[arg(long)]
662 relay: Option<String>,
663 #[arg(long)]
671 local_sister: bool,
672 #[arg(long)]
673 json: bool,
674 },
675 Up {
688 relay: Option<String>,
692 #[arg(long)]
695 name: Option<String>,
696 #[arg(long)]
701 with_local: Option<String>,
702 #[arg(long)]
704 no_local: bool,
705 #[arg(long)]
706 json: bool,
707 },
708 Doctor {
715 #[arg(long)]
717 json: bool,
718 #[arg(long, default_value_t = 5)]
720 recent_rejections: usize,
721 },
722 #[command(visible_alias = "update")]
734 Upgrade {
735 #[arg(long)]
737 check: bool,
738 #[arg(long)]
741 local: bool,
742 #[arg(long)]
743 json: bool,
744 },
745 Service {
750 #[command(subcommand)]
751 action: ServiceAction,
752 },
753 Diag {
758 #[command(subcommand)]
759 action: DiagAction,
760 },
761 #[command(hide = true)]
773 Claim {
774 nick: String,
776 #[arg(long)]
778 relay: Option<String>,
779 #[arg(long)]
781 public_url: Option<String>,
782 #[arg(long)]
790 hidden: bool,
791 #[arg(long)]
792 json: bool,
793 },
794 Profile {
804 #[command(subcommand)]
805 action: ProfileAction,
806 },
807 #[command(hide = true)] Invite {
812 #[arg(long, default_value = "https://wireup.net")]
814 relay: String,
815 #[arg(long, default_value_t = 86_400)]
817 ttl: u64,
818 #[arg(long, default_value_t = 1)]
821 uses: u32,
822 #[arg(long)]
826 share: bool,
827 #[arg(long)]
829 json: bool,
830 },
831 Accept {
841 target: String,
843 #[arg(long)]
845 json: bool,
846 },
847 #[command(alias = "invite-accept")]
855 AcceptInvite {
856 url: String,
858 #[arg(long)]
860 json: bool,
861 },
862 Reject {
865 peer: String,
867 #[arg(long)]
869 json: bool,
870 },
871 Notify {
876 #[arg(long, default_value_t = 2)]
878 interval: u64,
879 #[arg(long)]
881 peer: Option<String>,
882 #[arg(long)]
884 once: bool,
885 #[arg(long)]
889 json: bool,
890 },
891}
892
893#[derive(Subcommand, Debug)]
894pub enum DiagAction {
895 Tail {
897 #[arg(long, default_value_t = 20)]
898 limit: usize,
899 #[arg(long)]
900 json: bool,
901 },
902 Enable,
905 Disable,
907 Status {
909 #[arg(long)]
910 json: bool,
911 },
912}
913
914#[derive(Subcommand, Debug)]
915pub enum IdentityCommand {
916 Show {
919 #[arg(long)]
920 json: bool,
921 },
922 List {
927 #[arg(long)]
928 json: bool,
929 },
930 #[command(hide = true)]
938 Publish {
939 nick: String,
941 #[arg(long)]
944 relay: Option<String>,
945 #[arg(long, alias = "public")]
948 public_url: Option<String>,
949 #[arg(long)]
953 hidden: bool,
954 #[arg(long)]
955 json: bool,
956 },
957 Destroy {
961 name: String,
963 #[arg(long)]
965 force: bool,
966 #[arg(long)]
967 json: bool,
968 },
969 Create {
981 #[arg(long)]
984 name: Option<String>,
985 #[arg(long, conflicts_with = "local")]
988 anonymous: bool,
989 #[arg(long)]
992 local: bool,
993 #[arg(long)]
994 json: bool,
995 },
996 Persist {
1001 name: String,
1003 #[arg(long = "as", value_name = "NEW_NAME")]
1005 as_name: Option<String>,
1006 #[arg(long)]
1007 json: bool,
1008 },
1009 Demote {
1019 name: String,
1021 #[arg(long)]
1022 json: bool,
1023 },
1024}
1025
1026#[derive(Subcommand, Debug)]
1027pub enum SessionCommand {
1028 New {
1036 name: Option<String>,
1038 #[arg(long, default_value = "https://wireup.net")]
1040 relay: String,
1041 #[arg(long)]
1048 with_local: bool,
1049 #[arg(long, default_value = "http://127.0.0.1:8771")]
1053 local_relay: String,
1054 #[arg(long)]
1061 with_lan: bool,
1062 #[arg(long)]
1066 lan_relay: Option<String>,
1067 #[arg(long)]
1074 with_uds: bool,
1075 #[arg(long)]
1079 uds_socket: Option<std::path::PathBuf>,
1080 #[arg(long)]
1083 no_daemon: bool,
1084 #[arg(long)]
1092 local_only: bool,
1093 #[arg(long)]
1095 json: bool,
1096 },
1097 List {
1100 #[arg(long)]
1101 json: bool,
1102 },
1103 ListLocal {
1109 #[arg(long)]
1110 json: bool,
1111 },
1112 PairAllLocal {
1128 #[arg(long, default_value_t = 1)]
1133 settle_secs: u64,
1134 #[arg(long, default_value = "https://wireup.net")]
1139 federation_relay: String,
1140 #[arg(long)]
1141 json: bool,
1142 },
1143 MeshStatus {
1157 #[arg(long, default_value_t = 300)]
1162 stale_secs: u64,
1163 #[arg(long)]
1164 json: bool,
1165 },
1166 Env {
1170 name: Option<String>,
1172 #[arg(long)]
1173 json: bool,
1174 },
1175 Current {
1179 #[arg(long)]
1180 json: bool,
1181 },
1182 Bind {
1190 name: Option<String>,
1194 #[arg(long)]
1195 json: bool,
1196 },
1197 Destroy {
1201 name: String,
1202 #[arg(long)]
1204 force: bool,
1205 #[arg(long)]
1206 json: bool,
1207 },
1208}
1209
1210#[derive(Subcommand, Debug)]
1216pub enum GroupCommand {
1217 Create {
1219 name: String,
1221 #[arg(long)]
1222 json: bool,
1223 },
1224 Add {
1226 group: String,
1228 peer: String,
1230 #[arg(long)]
1231 json: bool,
1232 },
1233 Send {
1235 group: String,
1237 message: String,
1239 #[arg(long)]
1240 json: bool,
1241 },
1242 Tail {
1244 group: String,
1246 #[arg(long, default_value_t = 20)]
1248 limit: usize,
1249 #[arg(long)]
1250 json: bool,
1251 },
1252 List {
1254 #[arg(long)]
1255 json: bool,
1256 },
1257 Invite {
1262 group: String,
1264 #[arg(long)]
1265 json: bool,
1266 },
1267 Join {
1271 code: String,
1273 #[arg(long)]
1274 json: bool,
1275 },
1276}
1277
1278#[derive(Subcommand, Debug)]
1280pub enum MeshCommand {
1281 Status {
1284 #[arg(long, default_value_t = 300)]
1286 stale_secs: u64,
1287 #[arg(long)]
1288 json: bool,
1289 },
1290 Broadcast {
1309 #[arg(long, default_value = "claim")]
1312 kind: String,
1313 #[arg(long, default_value = "local")]
1315 scope: String,
1316 #[arg(long)]
1318 exclude: Vec<String>,
1319 #[arg(long)]
1323 noreply: bool,
1324 body: String,
1326 #[arg(long)]
1327 json: bool,
1328 },
1329 Role {
1338 #[command(subcommand)]
1339 action: MeshRoleAction,
1340 },
1341 Route {
1357 role: String,
1359 #[arg(long, default_value = "round-robin")]
1361 strategy: String,
1362 #[arg(long)]
1364 exclude: Vec<String>,
1365 #[arg(long, default_value = "claim")]
1368 kind: String,
1369 body: String,
1371 #[arg(long)]
1372 json: bool,
1373 },
1374}
1375
1376#[derive(Subcommand, Debug)]
1378pub enum MeshRoleAction {
1379 Set {
1384 role: String,
1385 #[arg(long)]
1386 json: bool,
1387 },
1388 Get {
1391 peer: Option<String>,
1392 #[arg(long)]
1393 json: bool,
1394 },
1395 List {
1398 #[arg(long)]
1399 json: bool,
1400 },
1401 Clear {
1404 #[arg(long)]
1405 json: bool,
1406 },
1407}
1408
1409#[derive(Subcommand, Debug)]
1410pub enum ServiceAction {
1411 Install {
1421 #[arg(long)]
1423 local_relay: bool,
1424 #[arg(long)]
1425 json: bool,
1426 },
1427 Uninstall {
1431 #[arg(long)]
1433 local_relay: bool,
1434 #[arg(long)]
1435 json: bool,
1436 },
1437 Status {
1439 #[arg(long)]
1441 local_relay: bool,
1442 #[arg(long)]
1443 json: bool,
1444 },
1445}
1446
1447#[derive(Subcommand, Debug)]
1448pub enum ResponderCommand {
1449 Set {
1451 status: String,
1453 #[arg(long)]
1455 reason: Option<String>,
1456 #[arg(long)]
1458 json: bool,
1459 },
1460 Get {
1462 peer: Option<String>,
1464 #[arg(long)]
1466 json: bool,
1467 },
1468}
1469
1470#[derive(Subcommand, Debug)]
1471pub enum ProfileAction {
1472 Set {
1476 field: String,
1477 value: String,
1478 #[arg(long)]
1479 json: bool,
1480 },
1481 Get {
1483 #[arg(long)]
1484 json: bool,
1485 },
1486 Clear {
1488 field: String,
1489 #[arg(long)]
1490 json: bool,
1491 },
1492}
1493
1494pub fn run() -> Result<()> {
1496 crate::session::maybe_adopt_session_wire_home("cli");
1507 let cli = Cli::parse();
1508 match cli.command {
1509 Command::Init {
1510 handle,
1511 name,
1512 relay,
1513 offline,
1514 json,
1515 } => cmd_init(
1516 Some(&handle),
1517 name.as_deref(),
1518 relay.as_deref(),
1519 offline,
1520 json,
1521 ),
1522 Command::Status { peer, json } => {
1523 if let Some(peer) = peer {
1524 cmd_status_peer(&peer, json)
1525 } else {
1526 cmd_status(json)
1527 }
1528 }
1529 Command::Whoami {
1530 json,
1531 short,
1532 colored,
1533 } => cmd_whoami(json_default(json), short, colored),
1534 Command::Peers { json } => cmd_peers(json_default(json)),
1535 Command::Here { json } => cmd_here(json_default(json)),
1536 Command::Completions { shell } => {
1537 use clap::CommandFactory;
1544 let mut cmd = Cli::command();
1545 clap_complete::generate(shell, &mut cmd, "wire", &mut std::io::stdout());
1546 Ok(())
1547 }
1548 Command::Pending { json } => cmd_pair_list_inbound(json_default(json)),
1549 Command::Reject { peer, json } => cmd_pair_reject(&peer, json_default(json)),
1550 Command::Send {
1551 peer,
1552 kind_or_body,
1553 body,
1554 deadline,
1555 no_auto_pair,
1556 json,
1557 } => {
1558 let (kind, body) = match body {
1561 Some(real_body) => (kind_or_body, real_body),
1562 None => ("claim".to_string(), kind_or_body),
1563 };
1564 cmd_send(
1565 &peer,
1566 &kind,
1567 &body,
1568 deadline.as_deref(),
1569 no_auto_pair,
1570 json_default(json),
1571 )
1572 }
1573 Command::Dial {
1574 name,
1575 message,
1576 json,
1577 } => cmd_dial(&name, message.as_deref(), json_default(json)),
1578 Command::Tail { peer, json, limit } => cmd_tail(peer.as_deref(), json, limit),
1579 Command::Monitor {
1580 peer,
1581 json,
1582 include_handshake,
1583 interval_ms,
1584 replay,
1585 } => cmd_monitor(
1586 peer.as_deref(),
1587 json,
1588 include_handshake,
1589 interval_ms,
1590 replay,
1591 ),
1592 Command::Verify { path, json } => cmd_verify(&path, json),
1593 Command::Responder { command } => match command {
1594 ResponderCommand::Set {
1595 status,
1596 reason,
1597 json,
1598 } => cmd_responder_set(&status, reason.as_deref(), json),
1599 ResponderCommand::Get { peer, json } => cmd_responder_get(peer.as_deref(), json),
1600 },
1601 Command::Mcp => cmd_mcp(),
1602 Command::RelayServer {
1603 bind,
1604 local_only,
1605 uds,
1606 } => cmd_relay_server(&bind, local_only, uds.as_deref()),
1607 Command::BindRelay {
1608 url,
1609 scope,
1610 replace,
1611 migrate_pinned,
1612 json,
1613 } => cmd_bind_relay(&url, scope.as_deref(), replace, migrate_pinned, json),
1614 Command::AddPeerSlot {
1615 handle,
1616 url,
1617 slot_id,
1618 slot_token,
1619 json,
1620 } => cmd_add_peer_slot(&handle, &url, &slot_id, &slot_token, json),
1621 Command::Push { peer, json } => cmd_push(peer.as_deref(), json),
1622 Command::Pull { json } => cmd_pull(json),
1623 Command::Pin { card_file, json } => cmd_pin(&card_file, json),
1624 Command::RotateSlot { no_announce, json } => cmd_rotate_slot(no_announce, json),
1625 Command::ForgetPeer {
1626 handle,
1627 purge,
1628 json,
1629 } => cmd_forget_peer(&handle, purge, json),
1630 Command::Daemon {
1631 interval,
1632 once,
1633 json,
1634 } => cmd_daemon(interval, once, json),
1635 Command::PairHost {
1636 relay,
1637 yes,
1638 timeout,
1639 detach,
1640 json,
1641 } => {
1642 if detach {
1643 cmd_pair_host_detach(&relay, json)
1644 } else {
1645 cmd_pair_host(&relay, yes, timeout)
1646 }
1647 }
1648 Command::PairJoin {
1649 code_phrase,
1650 relay,
1651 yes,
1652 timeout,
1653 detach,
1654 json,
1655 } => {
1656 if detach {
1657 cmd_pair_join_detach(&code_phrase, &relay, json)
1658 } else {
1659 cmd_pair_join(&code_phrase, &relay, yes, timeout)
1660 }
1661 }
1662 Command::PairConfirm {
1663 code_phrase,
1664 digits,
1665 json,
1666 } => cmd_pair_confirm(&code_phrase, &digits, json),
1667 Command::PairList {
1668 json,
1669 watch,
1670 watch_interval,
1671 } => cmd_pair_list(json, watch, watch_interval),
1672 Command::PairCancel { code_phrase, json } => cmd_pair_cancel(&code_phrase, json),
1673 Command::PairWatch {
1674 code_phrase,
1675 status,
1676 timeout,
1677 json,
1678 } => cmd_pair_watch(&code_phrase, &status, timeout, json),
1679 Command::Pair {
1680 handle,
1681 code,
1682 relay,
1683 yes,
1684 timeout,
1685 no_setup,
1686 detach,
1687 } => {
1688 if handle.contains('@') && code.is_none() {
1695 cmd_pair_megacommand(&handle, Some(&relay), timeout, false)
1696 } else if detach {
1697 cmd_pair_detach(&handle, code.as_deref(), &relay)
1698 } else {
1699 cmd_pair(&handle, code.as_deref(), &relay, yes, timeout, no_setup)
1700 }
1701 }
1702 Command::PairAbandon { code_phrase, relay } => cmd_pair_abandon(&code_phrase, &relay),
1703 Command::PairAccept { peer, json } => {
1704 let j = json_default(json);
1705 deprecation_warn("pair-accept", &format!("accept {peer}"), j);
1706 cmd_pair_accept(&peer, j)
1707 }
1708 Command::PairReject { peer, json } => {
1709 let j = json_default(json);
1710 deprecation_warn("pair-reject", &format!("reject {peer}"), j);
1711 cmd_pair_reject(&peer, j)
1712 }
1713 Command::PairListInbound { json } => {
1714 let j = json_default(json);
1715 deprecation_warn("pair-list-inbound", "pending", j);
1716 cmd_pair_list_inbound(j)
1717 }
1718 Command::Session(cmd) => cmd_session(cmd),
1719 Command::Identity { cmd } => cmd_identity(cmd),
1720 Command::Mesh(cmd) => cmd_mesh(cmd),
1721 Command::Group(cmd) => cmd_group(cmd),
1722 Command::Invite {
1723 relay,
1724 ttl,
1725 uses,
1726 share,
1727 json,
1728 } => cmd_invite(&relay, ttl, uses, share, json),
1729 Command::Accept { target, json } => {
1730 let j = json_default(json);
1736 if target.starts_with("wire://pair?") {
1737 deprecation_warn("accept-url", "accept-invite <url>", j);
1738 cmd_accept(&target, j)
1739 } else {
1740 cmd_pair_accept(&target, j)
1741 }
1742 }
1743 Command::AcceptInvite { url, json } => cmd_accept(&url, json_default(json)),
1744 Command::Whois {
1745 handle,
1746 json,
1747 relay,
1748 } => {
1749 match handle.as_deref() {
1758 Some(h) if !h.contains('@') => cmd_whois_local(h, json),
1759 other => cmd_whois(other, json, relay.as_deref()),
1760 }
1761 }
1762 Command::Add {
1763 handle,
1764 relay,
1765 local_sister,
1766 json,
1767 } => cmd_add(&handle, relay.as_deref(), local_sister, json),
1768 Command::Up {
1769 relay,
1770 name,
1771 with_local,
1772 no_local,
1773 json,
1774 } => cmd_up(
1775 relay.as_deref(),
1776 name.as_deref(),
1777 with_local.as_deref(),
1778 no_local,
1779 json,
1780 ),
1781 Command::Doctor {
1782 json,
1783 recent_rejections,
1784 } => cmd_doctor(json, recent_rejections),
1785 Command::Upgrade { check, local, json } => cmd_upgrade(check, local, json),
1786 Command::Service { action } => cmd_service(action),
1787 Command::Diag { action } => cmd_diag(action),
1788 Command::Claim {
1789 nick,
1790 relay,
1791 public_url,
1792 hidden,
1793 json,
1794 } => cmd_claim(&nick, relay.as_deref(), public_url.as_deref(), hidden, json),
1795 Command::Profile { action } => cmd_profile(action),
1796 Command::Setup {
1797 apply,
1798 statusline,
1799 remove,
1800 } => {
1801 if statusline {
1802 cmd_setup_statusline(apply, remove)
1803 } else {
1804 cmd_setup(apply)
1805 }
1806 }
1807 Command::Notify {
1808 interval,
1809 peer,
1810 once,
1811 json,
1812 } => cmd_notify(interval, peer.as_deref(), once, json),
1813 }
1814}
1815
1816fn cmd_init(
1819 handle: Option<&str>,
1820 name: Option<&str>,
1821 relay: Option<&str>,
1822 offline: bool,
1823 as_json: bool,
1824) -> Result<()> {
1825 if let Some(h) = handle
1831 && !h
1832 .chars()
1833 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
1834 {
1835 bail!("handle must be ASCII alphanumeric / '-' / '_' (got {h:?})");
1836 }
1837 if config::is_initialized()? {
1838 bail!(
1839 "already initialized — config exists at {:?}. Delete it first if you want a fresh identity.",
1840 config::config_dir()?
1841 );
1842 }
1843 let mut resolved_relay: Option<String> = relay.map(str::to_string);
1857 if resolved_relay.is_none() && !offline {
1858 let default_local = "http://127.0.0.1:8771";
1859 let client = crate::relay_client::RelayClient::new(default_local);
1860 if client.check_healthz().is_ok() {
1861 eprintln!(
1862 "wire init: local relay at {default_local} reachable — auto-attaching. \
1863 Use --relay <url> to pick a different relay, --offline to skip."
1864 );
1865 resolved_relay = Some(default_local.to_string());
1866 } else {
1867 use std::io::{BufRead, IsTerminal, Write};
1873 let interactive = std::io::stdin().is_terminal() && std::io::stderr().is_terminal();
1874 if interactive && std::env::var("WIRE_NO_INTERACTIVE").is_err() {
1875 eprintln!("wire init: no local relay reachable at {default_local}.");
1876 eprint!(
1877 " Bind to public federation relay https://wireup.net instead? \
1878 [Y/n/offline/url]: "
1879 );
1880 let _ = std::io::stderr().flush();
1881 let mut input = String::new();
1882 let _ = std::io::stdin().lock().read_line(&mut input);
1883 let answer = input.trim();
1884 match answer {
1885 "" | "y" | "Y" | "yes" | "YES" => {
1886 eprintln!("wire init: binding to https://wireup.net");
1887 resolved_relay = Some("https://wireup.net".to_string());
1888 }
1889 "n" | "N" | "no" | "NO" => {
1890 bail!(
1891 "wire init: declined federation default; re-run with --relay <url> or --offline."
1892 );
1893 }
1894 "offline" | "OFFLINE" => {
1895 eprintln!(
1896 "wire init: proceeding offline. \
1897 Run `wire bind-relay <url>` before pairing."
1898 );
1899 }
1905 url if url.starts_with("http://") || url.starts_with("https://") => {
1906 eprintln!("wire init: binding to {url}");
1907 resolved_relay = Some(url.to_string());
1908 }
1909 other => {
1910 bail!(
1911 "wire init: unrecognized answer `{other}` — \
1912 expected Y/n/offline/<url>. Re-run with --relay or --offline."
1913 );
1914 }
1915 }
1916 } else {
1917 bail!(
1918 "wire init: no relay specified and no local relay reachable at \
1919 http://127.0.0.1:8771.\n\
1920 Pick one (or just run `wire up`):\n\
1921 • `wire service install --local-relay` — start the local relay, then re-run\n\
1922 • `wire up @wireup.net` — bind to public federation in one command\n\
1923 • `wire init --offline` — generate keypair only \
1924 (peers cannot reach you until you `wire bind-relay <url>` later)"
1925 );
1926 }
1927 }
1928 }
1929 let relay = resolved_relay.as_deref();
1930
1931 config::ensure_dirs()?;
1932 let (sk_seed, pk_bytes) = generate_keypair();
1933 config::write_private_key(&sk_seed)?;
1934
1935 let seed = handle.unwrap_or("agent");
1953 let synth_did = crate::agent_card::did_for_with_key(seed, &pk_bytes);
1954 let character = crate::character::Character::from_did(&synth_did);
1955 let canonical_handle: &str = &character.nickname;
1956 if let Some(typed) = handle
1957 && typed != canonical_handle
1958 {
1959 eprintln!(
1960 "wire init: one-name rule — typed `{typed}` ignored in favor of \
1961 DID-derived persona `{canonical_handle}`. Peers will reach you as `{canonical_handle}`."
1962 );
1963 }
1964
1965 let card = build_agent_card(canonical_handle, &pk_bytes, name, None, None);
1966 let signed = sign_agent_card(&card, &sk_seed);
1967 config::write_agent_card(&signed)?;
1968
1969 let mut trust = empty_trust();
1970 add_self_to_trust(&mut trust, canonical_handle, &pk_bytes);
1971 config::write_trust(&trust)?;
1972
1973 let fp = fingerprint(&pk_bytes);
1974 let key_id = make_key_id(canonical_handle, &pk_bytes);
1975 let handle = canonical_handle;
1978
1979 let mut relay_info: Option<(String, String)> = None;
1981 if let Some(url) = relay {
1982 let normalized = url.trim_end_matches('/');
1983 let client = crate::relay_client::RelayClient::new(normalized);
1984 client.check_healthz()?;
1985 let alloc = client.allocate_slot(Some(handle))?;
1986 let mut state = config::read_relay_state()?;
1987 state["self"] = json!({
1988 "relay_url": normalized,
1989 "slot_id": alloc.slot_id.clone(),
1990 "slot_token": alloc.slot_token,
1991 });
1992 config::write_relay_state(&state)?;
1993 relay_info = Some((normalized.to_string(), alloc.slot_id));
1994 }
1995
1996 let did_str = crate::agent_card::did_for_with_key(handle, &pk_bytes);
1997 if as_json {
1998 let mut out = json!({
1999 "did": did_str.clone(),
2000 "fingerprint": fp,
2001 "key_id": key_id,
2002 "config_dir": config::config_dir()?.to_string_lossy(),
2003 });
2004 if let Some((url, slot_id)) = &relay_info {
2005 out["relay_url"] = json!(url);
2006 out["slot_id"] = json!(slot_id);
2007 }
2008 println!("{}", serde_json::to_string(&out)?);
2009 } else {
2010 println!("generated {did_str} (ed25519:{key_id})");
2011 println!(
2012 "config written to {}",
2013 config::config_dir()?.to_string_lossy()
2014 );
2015 if let Some((url, slot_id)) = &relay_info {
2016 println!("bound to relay {url} (slot {slot_id})");
2017 println!();
2018 println!(
2019 "next step: `wire pair-host --relay {url}` to print a code phrase for a peer."
2020 );
2021 } else {
2022 println!();
2023 println!(
2024 "next step: `wire pair-host --relay <url>` to bind a relay + open a pair-slot."
2025 );
2026 }
2027 }
2028 Ok(())
2029}
2030
2031fn cmd_status(as_json: bool) -> Result<()> {
2034 let initialized = config::is_initialized()?;
2035
2036 let mut summary = json!({
2037 "initialized": initialized,
2038 });
2039
2040 if initialized {
2041 let card = config::read_agent_card()?;
2042 let did = card
2043 .get("did")
2044 .and_then(Value::as_str)
2045 .unwrap_or("")
2046 .to_string();
2047 let handle = card
2051 .get("handle")
2052 .and_then(Value::as_str)
2053 .map(str::to_string)
2054 .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
2055 let pk_b64 = card
2056 .get("verify_keys")
2057 .and_then(Value::as_object)
2058 .and_then(|m| m.values().next())
2059 .and_then(|v| v.get("key"))
2060 .and_then(Value::as_str)
2061 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
2062 let pk_bytes = crate::signing::b64decode(pk_b64)?;
2063 summary["did"] = json!(did);
2064 summary["handle"] = json!(handle);
2065 summary["fingerprint"] = json!(fingerprint(&pk_bytes));
2066 summary["capabilities"] = card
2067 .get("capabilities")
2068 .cloned()
2069 .unwrap_or_else(|| json!([]));
2070
2071 let trust = config::read_trust()?;
2072 let relay_state_for_tier =
2073 config::read_relay_state().unwrap_or_else(|_| json!({"peers": {}}));
2074 let mut peers = Vec::new();
2075 if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
2076 for (peer_handle, _agent) in agents {
2077 if peer_handle == &handle {
2078 continue; }
2080 peers.push(json!({
2085 "handle": peer_handle,
2086 "tier": effective_peer_tier(&trust, &relay_state_for_tier, peer_handle),
2087 }));
2088 }
2089 }
2090 summary["peers"] = json!(peers);
2091
2092 let relay_state = config::read_relay_state()?;
2093 summary["self_relay"] = relay_state.get("self").cloned().unwrap_or(Value::Null);
2094 if !summary["self_relay"].is_null() {
2095 if let Some(obj) = summary["self_relay"].as_object_mut() {
2097 obj.remove("slot_token");
2098 }
2099 }
2100 summary["peer_slots_count"] = json!(
2101 relay_state
2102 .get("peers")
2103 .and_then(Value::as_object)
2104 .map(|m| m.len())
2105 .unwrap_or(0)
2106 );
2107
2108 let outbox = config::outbox_dir()?;
2110 let inbox = config::inbox_dir()?;
2111 summary["outbox"] = json!(scan_jsonl_dir(&outbox)?);
2112 summary["inbox"] = json!(scan_jsonl_dir(&inbox)?);
2113
2114 let snap = crate::ensure_up::daemon_liveness();
2120 let mut daemon = json!({
2121 "running": snap.pidfile_alive,
2122 "pid": snap.pidfile_pid,
2123 "all_running_pids": snap.pgrep_pids,
2124 "orphans": snap.orphan_pids,
2125 });
2126 if let crate::ensure_up::PidRecord::Json(d) = &snap.record {
2127 daemon["version"] = json!(d.version);
2128 daemon["bin_path"] = json!(d.bin_path);
2129 daemon["did"] = json!(d.did);
2130 daemon["relay_url"] = json!(d.relay_url);
2131 daemon["started_at"] = json!(d.started_at);
2132 daemon["schema"] = json!(d.schema);
2133 if d.version != env!("CARGO_PKG_VERSION") {
2134 daemon["version_mismatch"] = json!({
2135 "daemon": d.version.clone(),
2136 "cli": env!("CARGO_PKG_VERSION"),
2137 });
2138 }
2139 } else if matches!(snap.record, crate::ensure_up::PidRecord::LegacyInt(_)) {
2140 daemon["pidfile_form"] = json!("legacy-int");
2141 daemon["version_mismatch"] = json!({
2142 "daemon": "<pre-0.5.11>",
2143 "cli": env!("CARGO_PKG_VERSION"),
2144 });
2145 }
2146 summary["daemon"] = daemon;
2147
2148 let pending = crate::pending_pair::list_pending().unwrap_or_default();
2150 let mut counts: std::collections::BTreeMap<String, u32> = Default::default();
2151 for p in &pending {
2152 *counts.entry(p.status.clone()).or_default() += 1;
2153 }
2154 let pending_inbound =
2156 crate::pending_inbound_pair::list_pending_inbound().unwrap_or_default();
2157 let inbound_handles: Vec<&str> = pending_inbound
2158 .iter()
2159 .map(|p| p.peer_handle.as_str())
2160 .collect();
2161 summary["pending_pairs"] = json!({
2162 "total": pending.len(),
2163 "by_status": counts,
2164 "inbound_count": pending_inbound.len(),
2165 "inbound_handles": inbound_handles,
2166 });
2167 }
2168
2169 if as_json {
2170 println!("{}", serde_json::to_string(&summary)?);
2171 } else if !initialized {
2172 println!("not initialized — run `wire init <handle>` first");
2173 } else {
2174 println!("did: {}", summary["did"].as_str().unwrap_or("?"));
2175 println!(
2176 "fingerprint: {}",
2177 summary["fingerprint"].as_str().unwrap_or("?")
2178 );
2179 println!("capabilities: {}", summary["capabilities"]);
2180 if !summary["self_relay"].is_null() {
2181 println!(
2182 "self relay: {} (slot {})",
2183 summary["self_relay"]["relay_url"].as_str().unwrap_or("?"),
2184 summary["self_relay"]["slot_id"].as_str().unwrap_or("?")
2185 );
2186 } else {
2187 println!("self relay: (not bound — run `wire pair-host --relay <url>` to bind)");
2188 }
2189 println!(
2190 "peers: {}",
2191 summary["peers"].as_array().map(|a| a.len()).unwrap_or(0)
2192 );
2193 for p in summary["peers"].as_array().unwrap_or(&Vec::new()) {
2194 println!(
2195 " - {:<20} tier={}",
2196 p["handle"].as_str().unwrap_or(""),
2197 p["tier"].as_str().unwrap_or("?")
2198 );
2199 }
2200 println!(
2201 "outbox: {} file(s), {} event(s) queued",
2202 summary["outbox"]["files"].as_u64().unwrap_or(0),
2203 summary["outbox"]["events"].as_u64().unwrap_or(0)
2204 );
2205 println!(
2206 "inbox: {} file(s), {} event(s) received",
2207 summary["inbox"]["files"].as_u64().unwrap_or(0),
2208 summary["inbox"]["events"].as_u64().unwrap_or(0)
2209 );
2210 let daemon_running = summary["daemon"]["running"].as_bool().unwrap_or(false);
2211 let daemon_pid = summary["daemon"]["pid"]
2212 .as_u64()
2213 .map(|p| p.to_string())
2214 .unwrap_or_else(|| "—".to_string());
2215 let daemon_version = summary["daemon"]["version"].as_str().unwrap_or("");
2216 let version_suffix = if !daemon_version.is_empty() {
2217 format!(" v{daemon_version}")
2218 } else {
2219 String::new()
2220 };
2221 println!(
2222 "daemon: {} (pid {}{})",
2223 if daemon_running { "running" } else { "DOWN" },
2224 daemon_pid,
2225 version_suffix,
2226 );
2227 if let Some(mm) = summary["daemon"].get("version_mismatch") {
2229 println!(
2230 " !! version mismatch: daemon={} CLI={}. \
2231 run `wire upgrade` to swap atomically.",
2232 mm["daemon"].as_str().unwrap_or("?"),
2233 mm["cli"].as_str().unwrap_or("?"),
2234 );
2235 }
2236 if let Some(orphans) = summary["daemon"]["orphans"].as_array()
2237 && !orphans.is_empty()
2238 {
2239 let pids: Vec<String> = orphans
2240 .iter()
2241 .filter_map(|v| v.as_u64().map(|p| p.to_string()))
2242 .collect();
2243 println!(
2244 " !! orphan daemon process(es): pids {}. \
2245 pgrep saw them but pidfile didn't — likely stale process from \
2246 prior install. Multiple daemons race the relay cursor.",
2247 pids.join(", ")
2248 );
2249 }
2250 let pending_total = summary["pending_pairs"]["total"].as_u64().unwrap_or(0);
2251 let inbound_count = summary["pending_pairs"]["inbound_count"]
2252 .as_u64()
2253 .unwrap_or(0);
2254 if pending_total > 0 {
2255 print!("pending pairs: {pending_total}");
2256 if let Some(obj) = summary["pending_pairs"]["by_status"].as_object() {
2257 let parts: Vec<String> = obj
2258 .iter()
2259 .map(|(k, v)| format!("{}={}", k, v.as_u64().unwrap_or(0)))
2260 .collect();
2261 if !parts.is_empty() {
2262 print!(" ({})", parts.join(", "));
2263 }
2264 }
2265 println!();
2266 } else if inbound_count == 0 {
2267 println!("pending pairs: none");
2268 }
2269 if inbound_count > 0 {
2273 let handles: Vec<String> = summary["pending_pairs"]["inbound_handles"]
2274 .as_array()
2275 .map(|a| {
2276 a.iter()
2277 .filter_map(|v| v.as_str().map(str::to_string))
2278 .collect()
2279 })
2280 .unwrap_or_default();
2281 println!(
2282 "inbound pair requests ({inbound_count}): {} — `wire pair-list` to inspect, `wire pair-accept <peer>` to accept, `wire pair-reject <peer>` to refuse",
2283 handles.join(", "),
2284 );
2285 }
2286 }
2287 Ok(())
2288}
2289
2290fn scan_jsonl_dir(dir: &std::path::Path) -> Result<Value> {
2291 if !dir.exists() {
2292 return Ok(json!({"files": 0, "events": 0}));
2293 }
2294 let mut files = 0usize;
2295 let mut events = 0usize;
2296 for entry in std::fs::read_dir(dir)? {
2297 let path = entry?.path();
2298 if path.extension().map(|x| x == "jsonl").unwrap_or(false) {
2299 files += 1;
2300 if let Ok(body) = std::fs::read_to_string(&path) {
2301 events += body.lines().filter(|l| !l.trim().is_empty()).count();
2302 }
2303 }
2304 }
2305 Ok(json!({"files": files, "events": events}))
2306}
2307
2308fn responder_status_allowed(status: &str) -> bool {
2311 matches!(
2312 status,
2313 "online" | "offline" | "oauth_locked" | "rate_limited" | "degraded"
2314 )
2315}
2316
2317fn relay_slot_for(peer: Option<&str>) -> Result<(String, String, String, String)> {
2318 let state = config::read_relay_state()?;
2319 let (label, slot_info) = match peer {
2320 Some(peer) => (
2321 peer.to_string(),
2322 state
2323 .get("peers")
2324 .and_then(|p| p.get(peer))
2325 .ok_or_else(|| {
2326 anyhow!(
2327 "unknown peer {peer:?} in relay state — pair with them first:\n \
2328 wire add {peer}@wireup.net (or {peer}@<their-relay>)\n\
2329 (`wire peers` lists who you've already paired with.)"
2330 )
2331 })?,
2332 ),
2333 None => (
2334 "self".to_string(),
2335 state.get("self").filter(|v| !v.is_null()).ok_or_else(|| {
2336 anyhow!("self slot not bound — run `wire bind-relay <url>` first")
2337 })?,
2338 ),
2339 };
2340 let relay_url = slot_info["relay_url"]
2341 .as_str()
2342 .ok_or_else(|| anyhow!("{label} relay_url missing"))?
2343 .to_string();
2344 let slot_id = slot_info["slot_id"]
2345 .as_str()
2346 .ok_or_else(|| anyhow!("{label} slot_id missing"))?
2347 .to_string();
2348 let slot_token = slot_info["slot_token"]
2349 .as_str()
2350 .ok_or_else(|| anyhow!("{label} slot_token missing"))?
2351 .to_string();
2352 Ok((label, relay_url, slot_id, slot_token))
2353}
2354
2355fn cmd_responder_set(status: &str, reason: Option<&str>, as_json: bool) -> Result<()> {
2356 if !responder_status_allowed(status) {
2357 bail!("status must be one of: online, offline, oauth_locked, rate_limited, degraded");
2358 }
2359 let (_label, relay_url, slot_id, slot_token) = relay_slot_for(None)?;
2360 let now = time::OffsetDateTime::now_utc()
2361 .format(&time::format_description::well_known::Rfc3339)
2362 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
2363 let mut record = json!({
2364 "status": status,
2365 "set_at": now,
2366 });
2367 if let Some(reason) = reason {
2368 record["reason"] = json!(reason);
2369 }
2370 if status == "online" {
2371 record["last_success_at"] = json!(now);
2372 }
2373 let client = crate::relay_client::RelayClient::new(&relay_url);
2374 let saved = client.responder_health_set(&slot_id, &slot_token, &record)?;
2375 if as_json {
2376 println!("{}", serde_json::to_string(&saved)?);
2377 } else {
2378 let reason = saved
2379 .get("reason")
2380 .and_then(Value::as_str)
2381 .map(|r| format!(" — {r}"))
2382 .unwrap_or_default();
2383 println!(
2384 "responder {}{}",
2385 saved
2386 .get("status")
2387 .and_then(Value::as_str)
2388 .unwrap_or(status),
2389 reason
2390 );
2391 }
2392 Ok(())
2393}
2394
2395fn cmd_responder_get(peer: Option<&str>, as_json: bool) -> Result<()> {
2396 let (label, relay_url, slot_id, slot_token) = relay_slot_for(peer)?;
2397 let client = crate::relay_client::RelayClient::new(&relay_url);
2398 let health = client.responder_health_get(&slot_id, &slot_token)?;
2399 if as_json {
2400 println!(
2401 "{}",
2402 serde_json::to_string(&json!({
2403 "target": label,
2404 "responder_health": health,
2405 }))?
2406 );
2407 } else if health.is_null() {
2408 println!("{label}: responder health not reported");
2409 } else {
2410 let status = health
2411 .get("status")
2412 .and_then(Value::as_str)
2413 .unwrap_or("unknown");
2414 let reason = health
2415 .get("reason")
2416 .and_then(Value::as_str)
2417 .map(|r| format!(" — {r}"))
2418 .unwrap_or_default();
2419 let last_success = health
2420 .get("last_success_at")
2421 .and_then(Value::as_str)
2422 .map(|t| format!(" (last_success: {t})"))
2423 .unwrap_or_default();
2424 println!("{label}: {status}{reason}{last_success}");
2425 }
2426 Ok(())
2427}
2428
2429fn cmd_status_peer(peer: &str, as_json: bool) -> Result<()> {
2430 let (_label, relay_url, slot_id, slot_token) = relay_slot_for(Some(peer))?;
2431 let client = crate::relay_client::RelayClient::new(&relay_url);
2432
2433 let started = std::time::Instant::now();
2434 let transport_ok = client.healthz().unwrap_or(false);
2435 let latency_ms = started.elapsed().as_millis() as u64;
2436
2437 let (event_count, last_pull_at_unix) = client.slot_state(&slot_id, &slot_token)?;
2438 let now = std::time::SystemTime::now()
2439 .duration_since(std::time::UNIX_EPOCH)
2440 .map(|d| d.as_secs())
2441 .unwrap_or(0);
2442 let attention = match last_pull_at_unix {
2443 Some(last) if now.saturating_sub(last) <= 300 => json!({
2444 "status": "ok",
2445 "last_pull_at_unix": last,
2446 "age_seconds": now.saturating_sub(last),
2447 "event_count": event_count,
2448 }),
2449 Some(last) => json!({
2450 "status": "stale",
2451 "last_pull_at_unix": last,
2452 "age_seconds": now.saturating_sub(last),
2453 "event_count": event_count,
2454 }),
2455 None => json!({
2456 "status": "never_pulled",
2457 "last_pull_at_unix": Value::Null,
2458 "event_count": event_count,
2459 }),
2460 };
2461
2462 let responder_health = client.responder_health_get(&slot_id, &slot_token)?;
2463 let responder = if responder_health.is_null() {
2464 json!({"status": "not_reported", "record": Value::Null})
2465 } else {
2466 json!({
2467 "status": responder_health
2468 .get("status")
2469 .and_then(Value::as_str)
2470 .unwrap_or("unknown"),
2471 "record": responder_health,
2472 })
2473 };
2474
2475 let report = json!({
2476 "peer": peer,
2477 "transport": {
2478 "status": if transport_ok { "ok" } else { "error" },
2479 "relay_url": relay_url,
2480 "latency_ms": latency_ms,
2481 },
2482 "attention": attention,
2483 "responder": responder,
2484 });
2485
2486 if as_json {
2487 println!("{}", serde_json::to_string(&report)?);
2488 } else {
2489 let transport_line = if transport_ok {
2490 format!("ok relay reachable ({latency_ms}ms)")
2491 } else {
2492 "error relay unreachable".to_string()
2493 };
2494 println!("transport {transport_line}");
2495 match report["attention"]["status"].as_str().unwrap_or("unknown") {
2496 "ok" => println!(
2497 "attention ok last pull {}s ago",
2498 report["attention"]["age_seconds"].as_u64().unwrap_or(0)
2499 ),
2500 "stale" => println!(
2501 "attention stale last pull {}m ago",
2502 report["attention"]["age_seconds"].as_u64().unwrap_or(0) / 60
2503 ),
2504 "never_pulled" => println!("attention never pulled since relay reset"),
2505 other => println!("attention {other}"),
2506 }
2507 if report["responder"]["status"] == "not_reported" {
2508 println!("auto-responder not reported");
2509 } else {
2510 let record = &report["responder"]["record"];
2511 let status = record
2512 .get("status")
2513 .and_then(Value::as_str)
2514 .unwrap_or("unknown");
2515 let reason = record
2516 .get("reason")
2517 .and_then(Value::as_str)
2518 .map(|r| format!(" — {r}"))
2519 .unwrap_or_default();
2520 println!("auto-responder {status}{reason}");
2521 }
2522 }
2523 Ok(())
2524}
2525
2526fn current_cwd_display() -> String {
2534 let cwd = match std::env::current_dir() {
2535 Ok(c) => c,
2536 Err(_) => return String::from("?"),
2537 };
2538 if let Some(home) = dirs::home_dir()
2539 && let Ok(rel) = cwd.strip_prefix(&home)
2540 {
2541 let rel_str = rel.to_string_lossy();
2543 if rel_str.is_empty() {
2544 return String::from("~");
2545 }
2546 return format!("~/{}", rel_str);
2547 }
2548 cwd.to_string_lossy().into_owned()
2549}
2550
2551fn cmd_whoami(as_json: bool, short: bool, colored: bool) -> Result<()> {
2552 if !config::is_initialized()? {
2553 bail!("not initialized — run `wire init <handle>` first");
2554 }
2555 let card = config::read_agent_card()?;
2556 let did = card
2557 .get("did")
2558 .and_then(Value::as_str)
2559 .unwrap_or("")
2560 .to_string();
2561 let handle = card
2562 .get("handle")
2563 .and_then(Value::as_str)
2564 .map(str::to_string)
2565 .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
2566 let character = crate::character::Character::from_did(&did);
2570
2571 let cwd_display = current_cwd_display();
2577
2578 if short {
2581 println!("{} · {}", character.short(), cwd_display);
2582 return Ok(());
2583 }
2584 if colored {
2585 println!("{} \x1b[2m·\x1b[0m {}", character.colored(), cwd_display);
2586 return Ok(());
2587 }
2588
2589 let pk_b64 = card
2590 .get("verify_keys")
2591 .and_then(Value::as_object)
2592 .and_then(|m| m.values().next())
2593 .and_then(|v| v.get("key"))
2594 .and_then(Value::as_str)
2595 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
2596 let pk_bytes = crate::signing::b64decode(pk_b64)?;
2597 let fp = fingerprint(&pk_bytes);
2598 let key_id = make_key_id(&handle, &pk_bytes);
2599 let capabilities = card
2600 .get("capabilities")
2601 .cloned()
2602 .unwrap_or_else(|| json!(["wire/v3.1"]));
2603
2604 if as_json {
2605 let has_override = false;
2609 println!(
2610 "{}",
2611 serde_json::to_string(&json!({
2612 "did": did,
2613 "handle": handle,
2614 "fingerprint": fp,
2615 "key_id": key_id,
2616 "public_key_b64": pk_b64,
2617 "capabilities": capabilities,
2618 "config_dir": config::config_dir()?.to_string_lossy(),
2619 "persona": character,
2620 "persona_override": has_override,
2621 }))?
2622 );
2623 } else {
2624 println!("{}", character.colored());
2625 println!("{did} (ed25519:{key_id})");
2626 println!("fingerprint: {fp}");
2627 println!("capabilities: {capabilities}");
2628 }
2629 Ok(())
2630}
2631
2632fn cmd_identity(cmd: IdentityCommand) -> Result<()> {
2635 match cmd {
2636 IdentityCommand::Show { json } => cmd_whoami(json, !json, false),
2643 IdentityCommand::List { json } => cmd_session_list(json),
2644 IdentityCommand::Publish {
2645 nick,
2646 relay,
2647 public_url,
2648 hidden,
2649 json,
2650 } => cmd_claim(&nick, relay.as_deref(), public_url.as_deref(), hidden, json),
2651 IdentityCommand::Destroy { name, force, json } => cmd_session_destroy(&name, force, json),
2652 IdentityCommand::Create {
2653 name,
2654 anonymous,
2655 local: _,
2656 json,
2657 } => cmd_identity_create(name.as_deref(), anonymous, json),
2658 IdentityCommand::Persist {
2659 name,
2660 as_name,
2661 json,
2662 } => cmd_identity_persist(&name, as_name.as_deref(), json),
2663 IdentityCommand::Demote { name, json } => cmd_identity_demote(&name, json),
2664 }
2665}
2666
2667fn cmd_identity_create(name: Option<&str>, anonymous: bool, as_json: bool) -> Result<()> {
2672 if anonymous {
2673 let rand_suffix = format!("{:08x}", rand::random::<u32>());
2675 let anon_name = name
2676 .map(crate::session::sanitize_name)
2677 .unwrap_or_else(|| format!("anon-{rand_suffix}"));
2678 let anon_root = std::env::temp_dir().join(format!("wire-anon-{rand_suffix}"));
2679 std::fs::create_dir_all(&anon_root)
2680 .with_context(|| format!("creating anon root {anon_root:?}"))?;
2681 let session_home = anon_root.join("sessions").join(&anon_name);
2683 std::fs::create_dir_all(&session_home)?;
2684 let status = run_wire_with_home(&session_home, &["init", &anon_name, "--offline"])?;
2685 if !status.success() {
2686 bail!("anonymous identity init failed: {status}");
2687 }
2688 let marker = anon_root.join("anon-marker.json");
2691 std::fs::write(
2692 &marker,
2693 serde_json::to_vec_pretty(&serde_json::json!({
2694 "name": anon_name,
2695 "session_home": session_home.to_string_lossy(),
2696 "created_at": time::OffsetDateTime::now_utc()
2697 .format(&time::format_description::well_known::Rfc3339)
2698 .unwrap_or_default(),
2699 "kind": "anonymous",
2700 }))?,
2701 )?;
2702 let card = serde_json::from_slice::<Value>(&std::fs::read(
2703 session_home
2704 .join("config")
2705 .join("wire")
2706 .join("agent-card.json"),
2707 )?)?;
2708 let did = card
2709 .get("did")
2710 .and_then(Value::as_str)
2711 .unwrap_or("")
2712 .to_string();
2713 if as_json {
2714 println!(
2715 "{}",
2716 serde_json::to_string(&json!({
2717 "kind": "anonymous",
2718 "name": anon_name,
2719 "did": did,
2720 "session_home": session_home.to_string_lossy(),
2721 "anon_root": anon_root.to_string_lossy(),
2722 }))?
2723 );
2724 } else {
2725 println!("created anonymous identity `{anon_name}` ({did})");
2726 println!(
2727 " session_home: {} (dies on reboot — /tmp)",
2728 session_home.display()
2729 );
2730 println!();
2731 println!("activate in this shell:");
2732 println!(" export WIRE_HOME={}", session_home.display());
2733 println!();
2734 println!("promote to persistent later with:");
2735 println!(" wire identity persist {anon_name}");
2736 }
2737 return Ok(());
2738 }
2739 let name_arg = name.map(|s| s.to_string());
2741 cmd_session_new(
2742 name_arg.as_deref(),
2743 "https://wireup.net",
2744 false,
2745 "http://127.0.0.1:8771",
2746 false,
2747 None,
2748 false,
2749 None,
2750 true, true, as_json,
2753 )
2754}
2755
2756fn cmd_identity_persist(name: &str, as_name: Option<&str>, as_json: bool) -> Result<()> {
2759 let temp = std::env::temp_dir();
2761 let mut found: Option<(std::path::PathBuf, std::path::PathBuf)> = None;
2762 for entry in std::fs::read_dir(&temp)?.flatten() {
2763 let path = entry.path();
2764 if !path
2765 .file_name()
2766 .and_then(|s| s.to_str())
2767 .map(|s| s.starts_with("wire-anon-"))
2768 .unwrap_or(false)
2769 {
2770 continue;
2771 }
2772 let marker = path.join("anon-marker.json");
2773 if let Ok(bytes) = std::fs::read(&marker)
2774 && let Ok(json) = serde_json::from_slice::<Value>(&bytes)
2775 && json.get("name").and_then(Value::as_str) == Some(name)
2776 {
2777 let session_home = json
2778 .get("session_home")
2779 .and_then(Value::as_str)
2780 .map(std::path::PathBuf::from)
2781 .ok_or_else(|| anyhow!("anon-marker {marker:?} missing session_home"))?;
2782 found = Some((path, session_home));
2783 break;
2784 }
2785 }
2786 let (anon_root, anon_session_home) = found.ok_or_else(|| {
2787 anyhow!(
2788 "no anonymous identity named `{name}` found in /tmp/wire-anon-* — \
2789 run `wire identity list` to see available identities"
2790 )
2791 })?;
2792
2793 let new_name = as_name.unwrap_or(name);
2794 let new_session_home = crate::session::session_dir(new_name)?;
2795 if new_session_home.exists() {
2796 bail!(
2797 "target session `{new_name}` already exists at {new_session_home:?} — \
2798 pick a different name with --as <new-name>"
2799 );
2800 }
2801
2802 if let Some(parent) = new_session_home.parent() {
2804 std::fs::create_dir_all(parent)?;
2805 }
2806 std::fs::rename(&anon_session_home, &new_session_home)
2807 .with_context(|| format!("rename {anon_session_home:?} → {new_session_home:?}"))?;
2808
2809 let _ = std::fs::remove_dir_all(&anon_root);
2811
2812 let cwd = std::env::current_dir().unwrap_or_else(|_| new_session_home.clone());
2815 let cwd_key = cwd.to_string_lossy().into_owned();
2816 let new_name_for_reg = new_name.to_string();
2817 if let Err(e) = crate::session::update_registry(|reg| {
2818 reg.by_cwd.insert(cwd_key, new_name_for_reg);
2819 Ok(())
2820 }) {
2821 eprintln!("wire identity persist: failed to update registry: {e:#}");
2822 }
2823
2824 if as_json {
2825 println!(
2826 "{}",
2827 serde_json::to_string(&json!({
2828 "kind": "persisted",
2829 "from_name": name,
2830 "to_name": new_name,
2831 "session_home": new_session_home.to_string_lossy(),
2832 }))?
2833 );
2834 } else {
2835 println!("persisted anonymous identity `{name}` → local session `{new_name}`");
2836 println!(
2837 " session_home: {} (survives reboot)",
2838 new_session_home.display()
2839 );
2840 println!(" registered cwd: {}", cwd.display());
2841 }
2842 Ok(())
2843}
2844
2845fn cmd_identity_demote(name: &str, as_json: bool) -> Result<()> {
2851 let sessions = crate::session::list_sessions()?;
2852 let session = sessions
2853 .iter()
2854 .find(|s| s.name == name)
2855 .ok_or_else(|| anyhow!("no session named `{name}` (run `wire identity list`)"))?;
2856 let relay_state_path = session
2857 .home_dir
2858 .join("config")
2859 .join("wire")
2860 .join("relay.json");
2861 if !relay_state_path.exists() {
2862 bail!("session `{name}` has no relay state — already demoted?");
2863 }
2864 let mut state: Value = serde_json::from_slice(&std::fs::read(&relay_state_path)?)?;
2865 let self_obj = state.get("self").cloned().unwrap_or(Value::Null);
2866 let had_fed = self_obj
2867 .get("relay_url")
2868 .and_then(Value::as_str)
2869 .map(|u| {
2870 u.starts_with("https://") || (u.starts_with("http://") && !u.contains("127.0.0.1"))
2871 })
2872 .unwrap_or(false);
2873 if !had_fed {
2874 if as_json {
2875 println!(
2876 "{}",
2877 serde_json::to_string(
2878 &json!({"name": name, "status": "no-op", "reason": "no federation slot"})
2879 )?
2880 );
2881 } else {
2882 println!("session `{name}` has no federation slot — nothing to demote");
2883 }
2884 return Ok(());
2885 }
2886 if let Some(self_mut) = state
2889 .as_object_mut()
2890 .and_then(|m| m.get_mut("self"))
2891 .and_then(|s| s.as_object_mut())
2892 {
2893 self_mut.remove("relay_url");
2894 self_mut.remove("slot_id");
2895 self_mut.remove("slot_token");
2896 if let Some(eps) = self_mut.get_mut("endpoints").and_then(|e| e.as_array_mut()) {
2897 eps.retain(|ep| ep.get("scope").and_then(Value::as_str) != Some("federation"));
2898 }
2899 }
2900 std::fs::write(&relay_state_path, serde_json::to_vec_pretty(&state)?)?;
2901
2902 if as_json {
2903 println!(
2904 "{}",
2905 serde_json::to_string(
2906 &json!({"name": name, "status": "demoted", "from": "federation", "to": "local"})
2907 )?
2908 );
2909 } else {
2910 println!("demoted `{name}` from federation → local");
2911 println!(" relay slot binding removed; keypair + agent-card retained");
2912 println!(" re-publish with `wire identity publish <nick>`");
2913 }
2914 Ok(())
2915}
2916
2917fn effective_peer_tier(trust: &Value, relay_state: &Value, handle: &str) -> String {
2918 let raw = crate::trust::get_tier(trust, handle);
2919 if raw != "VERIFIED" {
2920 return raw.to_string();
2921 }
2922 let token = relay_state
2923 .get("peers")
2924 .and_then(|p| p.get(handle))
2925 .and_then(|p| p.get("slot_token"))
2926 .and_then(Value::as_str)
2927 .unwrap_or("");
2928 if token.is_empty() {
2929 "PENDING_ACK".to_string()
2930 } else {
2931 raw.to_string()
2932 }
2933}
2934
2935fn cmd_peers(as_json: bool) -> Result<()> {
2936 let trust = config::read_trust()?;
2937 let agents = trust
2938 .get("agents")
2939 .and_then(Value::as_object)
2940 .cloned()
2941 .unwrap_or_default();
2942 let relay_state = config::read_relay_state().unwrap_or_else(|_| json!({"peers": {}}));
2943
2944 let mut self_did: Option<String> = None;
2945 if let Ok(card) = config::read_agent_card() {
2946 self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
2947 }
2948
2949 let mut peers = Vec::new();
2950 for (handle, agent) in agents.iter() {
2951 let did = agent
2952 .get("did")
2953 .and_then(Value::as_str)
2954 .unwrap_or("")
2955 .to_string();
2956 if Some(did.as_str()) == self_did.as_deref() {
2957 continue; }
2959 let tier = effective_peer_tier(&trust, &relay_state, handle);
2960 let capabilities = agent
2961 .get("card")
2962 .and_then(|c| c.get("capabilities"))
2963 .cloned()
2964 .unwrap_or_else(|| json!([]));
2965 let character = if did.is_empty() {
2970 None
2971 } else {
2972 let card_obj = agent.get("card");
2973 Some(match card_obj {
2974 Some(card) => crate::character::Character::from_card(card),
2975 None => crate::character::Character::from_did(&did),
2976 })
2977 };
2978 peers.push(json!({
2979 "handle": handle,
2980 "did": did,
2981 "tier": tier,
2982 "capabilities": capabilities,
2983 "persona": character,
2984 }));
2985 }
2986
2987 if as_json {
2988 println!("{}", serde_json::to_string(&peers)?);
2989 } else if peers.is_empty() {
2990 println!("no peers pinned (run `wire join <code>` to pair)");
2991 } else {
2992 for p in &peers {
2998 let char_json = &p["persona"];
2999 let (colored_char, plain_len): (String, usize) = match char_json {
3000 serde_json::Value::Null => ("?".to_string(), 1),
3001 v => match serde_json::from_value::<crate::character::Character>(v.clone()) {
3002 Ok(c) => {
3003 let plain = c.short().chars().count() + 1; (c.colored(), plain)
3005 }
3006 Err(_) => ("?".to_string(), 1),
3007 },
3008 };
3009 let pad = 22usize.saturating_sub(plain_len);
3010 println!(
3011 "{}{} {:<20} {:<10} {}",
3012 colored_char,
3013 " ".repeat(pad),
3014 p["handle"].as_str().unwrap_or(""),
3015 p["tier"].as_str().unwrap_or(""),
3016 p["did"].as_str().unwrap_or(""),
3017 );
3018 }
3019 }
3020 Ok(())
3021}
3022
3023fn maybe_warn_peer_attentiveness(peer: &str) {
3033 let state = match config::read_relay_state() {
3034 Ok(s) => s,
3035 Err(_) => return,
3036 };
3037 let p = state.get("peers").and_then(|p| p.get(peer));
3038 let slot_id = match p.and_then(|p| p.get("slot_id")).and_then(Value::as_str) {
3039 Some(s) if !s.is_empty() => s,
3040 _ => return,
3041 };
3042 let slot_token = match p.and_then(|p| p.get("slot_token")).and_then(Value::as_str) {
3043 Some(s) if !s.is_empty() => s,
3044 _ => return,
3045 };
3046 let relay_url = match p.and_then(|p| p.get("relay_url")).and_then(Value::as_str) {
3047 Some(s) if !s.is_empty() => s.to_string(),
3048 _ => match state
3049 .get("self")
3050 .and_then(|s| s.get("relay_url"))
3051 .and_then(Value::as_str)
3052 {
3053 Some(s) if !s.is_empty() => s.to_string(),
3054 _ => return,
3055 },
3056 };
3057 let client = crate::relay_client::RelayClient::new(&relay_url);
3058 let (_count, last_pull) = match client.slot_state(slot_id, slot_token) {
3059 Ok(t) => t,
3060 Err(_) => return,
3061 };
3062 let now = std::time::SystemTime::now()
3063 .duration_since(std::time::UNIX_EPOCH)
3064 .map(|d| d.as_secs())
3065 .unwrap_or(0);
3066 match last_pull {
3067 None => {
3068 eprintln!(
3069 "phyllis: {peer}'s line is silent — relay sees no pulls yet. message will queue, but they may not be listening."
3070 );
3071 }
3072 Some(t) if now.saturating_sub(t) > 300 => {
3073 let mins = now.saturating_sub(t) / 60;
3074 eprintln!(
3075 "phyllis: {peer} hasn't picked up in {mins}m — message will queue, but they may be away."
3076 );
3077 }
3078 _ => {}
3079 }
3080}
3081
3082pub(crate) fn parse_deadline_until(input: &str) -> Result<String> {
3083 let trimmed = input.trim();
3084 if time::OffsetDateTime::parse(trimmed, &time::format_description::well_known::Rfc3339).is_ok()
3085 {
3086 return Ok(trimmed.to_string());
3087 }
3088 let (amount, unit) = trimmed.split_at(trimmed.len().saturating_sub(1));
3089 let n: i64 = amount
3090 .parse()
3091 .with_context(|| format!("deadline must be `30m`, `2h`, `1d`, or RFC3339: {input:?}"))?;
3092 if n <= 0 {
3093 bail!("deadline duration must be positive: {input:?}");
3094 }
3095 let duration = match unit {
3096 "m" => time::Duration::minutes(n),
3097 "h" => time::Duration::hours(n),
3098 "d" => time::Duration::days(n),
3099 _ => bail!("deadline must end in m, h, d, or be RFC3339: {input:?}"),
3100 };
3101 Ok((time::OffsetDateTime::now_utc() + duration)
3102 .format(&time::format_description::well_known::Rfc3339)
3103 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()))
3104}
3105
3106fn cmd_send(
3107 peer: &str,
3108 kind: &str,
3109 body_arg: &str,
3110 deadline: Option<&str>,
3111 no_auto_pair: bool,
3115 as_json: bool,
3116) -> Result<()> {
3117 if !config::is_initialized()? {
3118 bail!("not initialized — run `wire init <handle>` first");
3119 }
3120 let peer_in = crate::agent_card::bare_handle(peer).to_string();
3121 let peer = match resolve_peer_handle(&peer_in) {
3128 Ok(Some(resolved)) if resolved != peer_in => {
3129 eprintln!("wire send: resolved nickname `{peer_in}` → peer `{resolved}`");
3130 resolved
3131 }
3132 Ok(Some(canonical)) => canonical, Ok(None) => peer_in, Err(ResolveError::Ambiguous(candidates)) => bail!(
3135 "nickname `{peer_in}` is ambiguous — matches {} pinned peers: {}. \
3136 Disambiguate by passing the peer handle (one of those listed) instead of the nickname.",
3137 candidates.len(),
3138 candidates.join(", ")
3139 ),
3140 Err(ResolveError::NotFound) => peer_in, };
3142
3143 let peer_is_pinned = config::read_relay_state()
3150 .ok()
3151 .and_then(|s| s.get("peers").and_then(Value::as_object).cloned())
3152 .map(|peers| peers.contains_key(&peer))
3153 .unwrap_or(false);
3154 if !peer_is_pinned && let Some(sister_name) = crate::session::resolve_local_sister(&peer) {
3155 if no_auto_pair {
3156 bail!(
3157 "wire send: `{peer}` resolves to local sister `{sister_name}` but is not pinned, \
3158 and --no-auto-pair was passed. Run `wire dial {peer}` first, \
3159 then re-run send."
3160 );
3161 }
3162 eprintln!(
3163 "wire send: `{peer}` not pinned yet — auto-pairing via local-sister `{sister_name}` first. \
3164 Pass --no-auto-pair to refuse implicit dialing."
3165 );
3166 cmd_add_local_sister(&sister_name, true).map_err(|e| {
3167 anyhow!("wire send: auto-pair to local sister `{sister_name}` failed: {e:#}")
3168 })?;
3169 }
3170
3171 let peer = peer.as_str();
3172 let sk_seed = config::read_private_key()?;
3173 let card = config::read_agent_card()?;
3174 let did = card.get("did").and_then(Value::as_str).unwrap_or("");
3175 let handle = crate::agent_card::display_handle_from_did(did).to_string();
3176 let pk_b64 = card
3177 .get("verify_keys")
3178 .and_then(Value::as_object)
3179 .and_then(|m| m.values().next())
3180 .and_then(|v| v.get("key"))
3181 .and_then(Value::as_str)
3182 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
3183 let pk_bytes = crate::signing::b64decode(pk_b64)?;
3184
3185 let body_value: Value = if body_arg == "-" {
3190 use std::io::Read;
3191 let mut raw = String::new();
3192 std::io::stdin()
3193 .read_to_string(&mut raw)
3194 .with_context(|| "reading body from stdin")?;
3195 serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
3198 } else if let Some(path) = body_arg.strip_prefix('@') {
3199 let raw =
3200 std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
3201 serde_json::from_str(&raw).unwrap_or(Value::String(raw))
3202 } else {
3203 Value::String(body_arg.to_string())
3204 };
3205
3206 let kind_id = parse_kind(kind)?;
3207
3208 let now = time::OffsetDateTime::now_utc()
3209 .format(&time::format_description::well_known::Rfc3339)
3210 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
3211
3212 let mut event = json!({
3213 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
3214 "timestamp": now,
3215 "from": did,
3216 "to": format!("did:wire:{peer}"),
3217 "type": kind,
3218 "kind": kind_id,
3219 "body": body_value,
3220 });
3221 if let Some(deadline) = deadline {
3222 event["time_sensitive_until"] = json!(parse_deadline_until(deadline)?);
3223 }
3224 let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)?;
3225 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
3226
3227 maybe_warn_peer_attentiveness(peer);
3232
3233 let line = serde_json::to_vec(&signed)?;
3238 let outbox = config::append_outbox_record(peer, &line)?;
3239
3240 if as_json {
3241 println!(
3242 "{}",
3243 serde_json::to_string(&json!({
3244 "event_id": event_id,
3245 "status": "queued",
3246 "peer": peer,
3247 "outbox": outbox.to_string_lossy(),
3248 }))?
3249 );
3250 } else {
3251 println!(
3252 "queued event {event_id} → {peer} (outbox: {})",
3253 outbox.display()
3254 );
3255 }
3256 Ok(())
3257}
3258
3259fn parse_kind(s: &str) -> Result<u32> {
3260 if let Ok(n) = s.parse::<u32>() {
3261 return Ok(n);
3262 }
3263 for (id, name) in crate::signing::kinds() {
3264 if *name == s {
3265 return Ok(*id);
3266 }
3267 }
3268 Ok(1)
3270}
3271
3272fn cmd_here(as_json: bool) -> Result<()> {
3278 let initialized = config::is_initialized().unwrap_or(false);
3279
3280 let (self_did, self_handle, self_character) = if initialized {
3282 let card = config::read_agent_card().ok();
3283 let did = card
3284 .as_ref()
3285 .and_then(|c| c.get("did").and_then(Value::as_str))
3286 .unwrap_or("")
3287 .to_string();
3288 let handle = if did.is_empty() {
3289 String::new()
3290 } else {
3291 crate::agent_card::display_handle_from_did(&did).to_string()
3292 };
3293 let character = if did.is_empty() {
3294 None
3295 } else {
3296 Some(crate::character::Character::from_did(&did))
3298 };
3299 (did, handle, character)
3300 } else {
3301 (String::new(), String::new(), None)
3302 };
3303
3304 let cwd = std::env::current_dir()
3305 .map(|p| p.to_string_lossy().into_owned())
3306 .unwrap_or_default();
3307 let wire_home = std::env::var("WIRE_HOME").unwrap_or_default();
3308
3309 let mut sisters: Vec<Value> = Vec::new();
3311 if let Ok(listing) = crate::session::list_local_sessions() {
3312 for group in listing.local.values() {
3313 for s in group {
3314 if s.handle.as_deref() == Some(self_handle.as_str()) {
3315 continue; }
3317 let ch = s.did.as_deref().map(crate::character::Character::from_did);
3318 sisters.push(json!({
3319 "session": s.name,
3320 "handle": s.handle,
3321 "persona": ch,
3322 }));
3323 }
3324 }
3325 }
3326
3327 let mut peers: Vec<Value> = Vec::new();
3329 if initialized
3330 && let Ok(trust) = config::read_trust()
3331 && let Some(agents) = trust.get("agents").and_then(Value::as_object)
3332 {
3333 for (handle, agent) in agents {
3334 if handle == &self_handle {
3335 continue; }
3337 let did = agent.get("did").and_then(Value::as_str).unwrap_or("");
3338 let ch = if did.is_empty() {
3339 None
3340 } else {
3341 Some(crate::character::Character::from_did(did))
3342 };
3343 peers.push(json!({
3344 "handle": handle,
3345 "did": did,
3346 "tier": agent.get("tier").and_then(Value::as_str).unwrap_or("UNKNOWN"),
3347 "persona": ch,
3348 }));
3349 }
3350 }
3351
3352 if as_json {
3353 println!(
3354 "{}",
3355 serde_json::to_string(&json!({
3356 "self": {
3357 "handle": self_handle,
3358 "did": self_did,
3359 "persona": self_character,
3360 "cwd": cwd,
3361 "wire_home": wire_home,
3362 },
3363 "sister_sessions": sisters,
3364 "pinned_peers": peers,
3365 }))?
3366 );
3367 return Ok(());
3368 }
3369
3370 if !initialized {
3372 println!("not initialized — run `wire init <handle>` to bootstrap.");
3373 return Ok(());
3374 }
3375 let glyph = self_character
3376 .as_ref()
3377 .map(crate::character::emoji_with_fallback)
3378 .unwrap_or_else(|| "?".to_string());
3379 let nick = self_character
3380 .as_ref()
3381 .map(|c| c.nickname.clone())
3382 .unwrap_or_default();
3383 println!("you are {glyph} {nick} ({self_handle})");
3384 if !cwd.is_empty() {
3385 println!(" cwd: {cwd}");
3386 }
3387 let render_glyph = |character: &Value| -> String {
3392 let emoji = character
3393 .get("emoji")
3394 .and_then(Value::as_str)
3395 .unwrap_or("?");
3396 let nickname = character
3397 .get("nickname")
3398 .and_then(Value::as_str)
3399 .unwrap_or("?");
3400 if crate::character::terminal_supports_emoji() {
3401 return emoji.to_string();
3402 }
3403 let synth = crate::character::Character {
3406 nickname: nickname.to_string(),
3407 emoji: emoji.to_string(),
3408 palette: crate::character::Palette {
3409 primary_hex: String::new(),
3410 accent_hex: String::new(),
3411 ansi256_primary: 0,
3412 ansi256_accent: 0,
3413 },
3414 };
3415 crate::character::emoji_with_fallback(&synth)
3416 };
3417 if !sisters.is_empty() {
3418 println!();
3419 println!("sister sessions on this machine:");
3420 for s in &sisters {
3421 let session = s["session"].as_str().unwrap_or("?");
3422 let ch_nick = s["persona"]["nickname"].as_str().unwrap_or("?");
3423 let glyph = render_glyph(&s["persona"]);
3424 println!(" {glyph} {ch_nick} ({session})");
3425 }
3426 }
3427 if !peers.is_empty() {
3428 println!();
3429 println!("pinned peers:");
3430 for p in &peers {
3431 let handle = p["handle"].as_str().unwrap_or("?");
3432 let tier = p["tier"].as_str().unwrap_or("");
3433 let ch_nick = p["persona"]["nickname"].as_str().unwrap_or("?");
3434 let glyph = render_glyph(&p["persona"]);
3435 println!(" {glyph} {ch_nick} ({handle}) [{tier}]");
3436 }
3437 }
3438 if sisters.is_empty() && peers.is_empty() {
3439 println!();
3440 println!(
3441 "no neighbors yet — `wire session new` to add a sister, or `wire dial <peer>` to reach out."
3442 );
3443 }
3444 Ok(())
3445}
3446
3447fn cmd_dial(name: &str, message: Option<&str>, as_json: bool) -> Result<()> {
3459 if name.contains('@') {
3460 cmd_add(name, None, false, true)
3466 .map_err(|e| anyhow!("wire dial: federation pair to `{name}` failed: {e:#}"))?;
3467 if let Some(msg) = message {
3468 let bare = name.split('@').next().unwrap_or(name);
3470 cmd_send(bare, "claim", msg, None, false, as_json)?;
3471 }
3472 return Ok(());
3473 }
3474
3475 let resolution = match resolve_name_to_target(name) {
3480 Ok(r) => r,
3481 Err(e) if as_json => {
3482 let pool = known_local_names();
3483 let suggestions = closest_candidates(name, &pool, 3, 3);
3484 println!(
3485 "{}",
3486 serde_json::to_string(&json!({
3487 "name_input": name,
3488 "found": false,
3489 "candidates": suggestions,
3490 "error": format!("{e:#}"),
3491 }))?
3492 );
3493 return Ok(());
3494 }
3495 Err(e) => return Err(e),
3496 };
3497 let mut steps: Vec<Value> = Vec::new();
3498
3499 match &resolution {
3500 DialTarget::PinnedPeer { handle, .. } => {
3501 steps.push(json!({
3502 "step": "resolved",
3503 "kind": "already_pinned",
3504 "handle": handle,
3505 }));
3506 }
3507 DialTarget::LocalSister { session_name, .. } => {
3508 steps.push(json!({
3509 "step": "resolved",
3510 "kind": "local_sister",
3511 "session": session_name,
3512 }));
3513 cmd_add_local_sister(session_name, true).map_err(|e| {
3519 anyhow!("dial: local-sister pair to `{session_name}` failed: {e:#}")
3520 })?;
3521 steps.push(json!({
3522 "step": "paired",
3523 "via": "local_sister",
3524 }));
3525 }
3526 }
3527
3528 let send_handle = match &resolution {
3529 DialTarget::PinnedPeer { handle, .. } => handle.clone(),
3530 DialTarget::LocalSister { handle, .. } => handle.clone(),
3531 };
3532
3533 let send_result = if let Some(msg) = message {
3534 let r = cmd_send(&send_handle, "claim", msg, None, false, true);
3535 match &r {
3536 Ok(()) => steps.push(json!({"step": "sent", "to": send_handle, "kind": "claim"})),
3537 Err(e) => steps.push(json!({"step": "send_failed", "error": format!("{e:#}")})),
3538 }
3539 Some(r)
3540 } else {
3541 None
3542 };
3543
3544 if as_json {
3545 println!(
3546 "{}",
3547 serde_json::to_string(&json!({
3548 "name_input": name,
3549 "resolved_handle": send_handle,
3550 "steps": steps,
3551 }))?
3552 );
3553 } else {
3554 println!("wire dial: resolved `{name}` → handle `{send_handle}`");
3555 for s in &steps {
3556 let step = s.get("step").and_then(Value::as_str).unwrap_or("?");
3557 println!(" - {step}");
3558 }
3559 if message.is_some() {
3560 println!(" (use `wire tail {send_handle}` to read replies)");
3561 }
3562 }
3563 if let Some(Err(e)) = send_result {
3564 return Err(e);
3565 }
3566 Ok(())
3567}
3568
3569fn cmd_whois_local(name: &str, as_json: bool) -> Result<()> {
3575 let resolution = match resolve_name_to_target(name) {
3581 Ok(r) => r,
3582 Err(e) if as_json => {
3583 let pool = known_local_names();
3584 let suggestions = closest_candidates(name, &pool, 3, 3);
3585 println!(
3586 "{}",
3587 serde_json::to_string(&json!({
3588 "name_input": name,
3589 "found": false,
3590 "candidates": suggestions,
3591 "error": format!("{e:#}"),
3592 }))?
3593 );
3594 return Ok(());
3595 }
3596 Err(e) => return Err(e),
3597 };
3598 match resolution {
3599 DialTarget::PinnedPeer {
3600 handle,
3601 did,
3602 nickname,
3603 emoji,
3604 tier,
3605 } => {
3606 if as_json {
3607 println!(
3608 "{}",
3609 serde_json::to_string(&json!({
3610 "kind": "pinned_peer",
3611 "handle": handle,
3612 "did": did,
3613 "nickname": nickname,
3614 "emoji": emoji,
3615 "tier": tier,
3616 }))?
3617 );
3618 } else {
3619 let n = nickname.as_deref().unwrap_or("(no character)");
3620 let e = emoji.as_deref().unwrap_or("?");
3621 println!("{e} {n}");
3622 println!(" handle: {handle}");
3623 println!(" did: {did}");
3624 println!(" tier: {tier}");
3625 println!(" reach: pinned peer (already in trust ring + slot pinned)");
3626 }
3627 }
3628 DialTarget::LocalSister {
3629 session_name,
3630 handle,
3631 did,
3632 nickname,
3633 emoji,
3634 } => {
3635 if as_json {
3636 println!(
3637 "{}",
3638 serde_json::to_string(&json!({
3639 "kind": "local_sister",
3640 "session_name": session_name,
3641 "handle": handle,
3642 "did": did,
3643 "nickname": nickname,
3644 "emoji": emoji,
3645 }))?
3646 );
3647 } else {
3648 let n = nickname.as_deref().unwrap_or("(no character)");
3649 let e = emoji.as_deref().unwrap_or("?");
3650 println!("{e} {n}");
3651 println!(" session: {session_name}");
3652 println!(" handle: {handle}");
3653 println!(
3654 " did: {}",
3655 did.as_deref().unwrap_or("(card unreadable)")
3656 );
3657 println!(" reach: local sister on this machine — `wire dial {n}` pairs us");
3658 }
3659 }
3660 }
3661 Ok(())
3662}
3663
3664enum DialTarget {
3665 PinnedPeer {
3666 handle: String,
3667 did: String,
3668 nickname: Option<String>,
3669 emoji: Option<String>,
3670 tier: String,
3671 },
3672 LocalSister {
3673 session_name: String,
3674 handle: String,
3675 did: Option<String>,
3676 nickname: Option<String>,
3677 emoji: Option<String>,
3678 },
3679}
3680
3681fn resolve_name_to_target(name: &str) -> Result<DialTarget> {
3685 let needle = name.trim();
3686 if needle.is_empty() {
3687 bail!("empty name");
3688 }
3689
3690 if config::is_initialized().unwrap_or(false) {
3693 let trust = config::read_trust().unwrap_or(serde_json::Value::Null);
3694 if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
3695 for (handle_key, agent) in agents {
3696 let did = agent.get("did").and_then(Value::as_str).unwrap_or("");
3697 if did.is_empty() {
3698 continue;
3699 }
3700 let handle = handle_key.clone();
3701 let character = crate::character::Character::from_did(did);
3702 let tier = agent
3703 .get("tier")
3704 .and_then(Value::as_str)
3705 .unwrap_or("UNKNOWN")
3706 .to_string();
3707 let matches = handle.eq_ignore_ascii_case(needle)
3708 || did.eq_ignore_ascii_case(needle)
3709 || character.nickname.eq_ignore_ascii_case(needle);
3710 if matches {
3711 return Ok(DialTarget::PinnedPeer {
3712 handle,
3713 did: did.to_string(),
3714 nickname: Some(character.nickname),
3715 emoji: Some(character.emoji.to_string()),
3716 tier,
3717 });
3718 }
3719 }
3720 }
3721 }
3722
3723 if let Some(session_name) = crate::session::resolve_local_sister(needle) {
3725 let sessions = crate::session::list_sessions().unwrap_or_default();
3726 let s = sessions.iter().find(|s| s.name == session_name);
3727 if let Some(s) = s {
3728 return Ok(DialTarget::LocalSister {
3729 session_name: s.name.clone(),
3730 handle: s.handle.clone().unwrap_or_else(|| s.name.clone()),
3731 did: s.did.clone(),
3732 nickname: s.character.as_ref().map(|c| c.nickname.clone()),
3733 emoji: s.character.as_ref().map(|c| c.emoji.to_string()),
3734 });
3735 }
3736 }
3737
3738 let pool = known_local_names();
3743 let suggestions = closest_candidates(name, &pool, 3, 3);
3744 if suggestions.is_empty() {
3745 bail!(
3746 "no peer matched `{name}`.\n\
3747 Tried: pinned peers (`wire peers`) + local sister sessions \
3748 (`wire session list-local`).\n\
3749 For cross-machine federation: `wire dial <handle>@<relay-domain>`."
3750 );
3751 }
3752 bail!(
3753 "no peer matched `{name}`.\n\
3754 Did you mean: {}?\n\
3755 List all: `wire peers`, `wire session list-local`.",
3756 suggestions
3757 .iter()
3758 .map(|s| format!("`{s}`"))
3759 .collect::<Vec<_>>()
3760 .join(", ")
3761 );
3762}
3763
3764fn cmd_tail(peer: Option<&str>, as_json: bool, limit: usize) -> Result<()> {
3767 let inbox = config::inbox_dir()?;
3768 if !inbox.exists() {
3769 if !as_json {
3770 eprintln!("no inbox yet — daemon hasn't run, or no events received");
3771 }
3772 return Ok(());
3773 }
3774 let trust = config::read_trust()?;
3775 let mut count = 0usize;
3776
3777 let entries: Vec<_> = std::fs::read_dir(&inbox)?
3778 .filter_map(|e| e.ok())
3779 .map(|e| e.path())
3780 .filter(|p| {
3781 p.extension().map(|x| x == "jsonl").unwrap_or(false)
3782 && match peer {
3783 Some(want) => p.file_stem().and_then(|s| s.to_str()) == Some(want),
3784 None => true,
3785 }
3786 })
3787 .collect();
3788
3789 for path in entries {
3790 let body = std::fs::read_to_string(&path)?;
3791 for line in body.lines() {
3792 let event: Value = match serde_json::from_str(line) {
3793 Ok(v) => v,
3794 Err(_) => continue,
3795 };
3796 let verified = verify_message_v31(&event, &trust).is_ok();
3797 if as_json {
3798 let mut event_with_meta = event.clone();
3799 if let Some(obj) = event_with_meta.as_object_mut() {
3800 obj.insert("verified".into(), json!(verified));
3801 }
3802 println!("{}", serde_json::to_string(&event_with_meta)?);
3803 } else {
3804 let ts = event
3805 .get("timestamp")
3806 .and_then(Value::as_str)
3807 .unwrap_or("?");
3808 let from = event.get("from").and_then(Value::as_str).unwrap_or("?");
3809 let kind = event.get("kind").and_then(Value::as_u64).unwrap_or(0);
3810 let kind_name = event.get("type").and_then(Value::as_str).unwrap_or("?");
3811 let summary = event
3812 .get("body")
3813 .map(|b| match b {
3814 Value::String(s) => s.clone(),
3815 _ => b.to_string(),
3816 })
3817 .unwrap_or_default();
3818 let mark = if verified { "✓" } else { "✗" };
3819 let deadline = event
3820 .get("time_sensitive_until")
3821 .and_then(Value::as_str)
3822 .map(|d| format!(" deadline: {d}"))
3823 .unwrap_or_default();
3824 println!("[{ts} {from} kind={kind} {kind_name}{deadline}] {summary} | sig {mark}");
3825 }
3826 count += 1;
3827 if limit > 0 && count >= limit {
3828 return Ok(());
3829 }
3830 }
3831 }
3832 Ok(())
3833}
3834
3835fn monitor_is_noise_kind(kind: &str) -> bool {
3841 matches!(kind, "pair_drop" | "pair_drop_ack" | "heartbeat")
3842}
3843
3844fn resolve_persona(peer_handle: &str) -> Option<crate::character::Character> {
3848 let trust = config::read_trust().ok()?;
3849 let agent = trust.get("agents").and_then(|a| a.get(peer_handle))?;
3850 if let Some(card) = agent.get("card") {
3851 Some(crate::character::Character::from_card(card))
3852 } else {
3853 let did = agent.get("did").and_then(Value::as_str)?;
3854 Some(crate::character::Character::from_did(did))
3855 }
3856}
3857
3858fn persona_label(peer_handle: &str) -> String {
3860 match resolve_persona(peer_handle) {
3861 Some(ch) => format!("{} {}", ch.emoji, ch.nickname),
3862 None => peer_handle.to_string(),
3863 }
3864}
3865
3866fn monitor_render(e: &crate::inbox_watch::InboxEvent, as_json: bool) -> Result<String> {
3874 if as_json {
3875 Ok(serde_json::to_string(e)?)
3876 } else {
3877 let eid_short: String = e.event_id.chars().take(12).collect();
3878 let body = e.body_preview.replace('\n', " ");
3879 let ts: String = e.timestamp.chars().take(19).collect();
3880 Ok(format!("[{ts}] {}/{} ({eid_short}) {body}", e.peer, e.kind))
3881 }
3882}
3883
3884fn cmd_monitor(
3900 peer_filter: Option<&str>,
3901 as_json: bool,
3902 include_handshake: bool,
3903 interval_ms: u64,
3904 replay: usize,
3905) -> Result<()> {
3906 let inbox_dir = config::inbox_dir()?;
3907 if !inbox_dir.exists() && !as_json {
3908 eprintln!("wire monitor: inbox dir {inbox_dir:?} missing — has the daemon ever run?");
3909 }
3910 if replay > 0 && inbox_dir.exists() {
3916 let mut all: Vec<crate::inbox_watch::InboxEvent> = Vec::new();
3917 for entry in std::fs::read_dir(&inbox_dir)?.flatten() {
3918 let path = entry.path();
3919 if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
3920 continue;
3921 }
3922 let peer = match path.file_stem().and_then(|s| s.to_str()) {
3923 Some(s) => s.to_string(),
3924 None => continue,
3925 };
3926 if let Some(filter) = peer_filter
3927 && peer != filter
3928 {
3929 continue;
3930 }
3931 let body = std::fs::read_to_string(&path).unwrap_or_default();
3932 for line in body.lines() {
3933 let line = line.trim();
3934 if line.is_empty() {
3935 continue;
3936 }
3937 let signed: Value = match serde_json::from_str(line) {
3938 Ok(v) => v,
3939 Err(_) => continue,
3940 };
3941 let ev = crate::inbox_watch::InboxEvent::from_signed(
3942 &peer, signed, true,
3943 );
3944 if !include_handshake && monitor_is_noise_kind(&ev.kind) {
3945 continue;
3946 }
3947 all.push(ev);
3948 }
3949 }
3950 all.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
3953 let start = all.len().saturating_sub(replay);
3954 for ev in &all[start..] {
3955 println!("{}", monitor_render(ev, as_json)?);
3956 }
3957 use std::io::Write;
3958 std::io::stdout().flush().ok();
3959 }
3960
3961 let mut w = crate::inbox_watch::InboxWatcher::from_head()?;
3964 let sleep_dur = std::time::Duration::from_millis(interval_ms.max(50));
3965
3966 loop {
3967 let events = match w.poll() {
3974 Ok(evs) => evs,
3975 Err(e) => {
3976 eprintln!("wire monitor: poll error (continuing to watch): {e:#}");
3977 std::thread::sleep(sleep_dur);
3978 continue;
3979 }
3980 };
3981 let mut wrote = false;
3982 for ev in events {
3983 if let Some(filter) = peer_filter
3984 && ev.peer != filter
3985 {
3986 continue;
3987 }
3988 if !include_handshake && monitor_is_noise_kind(&ev.kind) {
3989 continue;
3990 }
3991 println!("{}", monitor_render(&ev, as_json)?);
3992 wrote = true;
3993 }
3994 if wrote {
3995 use std::io::Write;
3996 std::io::stdout().flush().ok();
3997 }
3998 std::thread::sleep(sleep_dur);
3999 }
4000}
4001
4002#[cfg(test)]
4003mod tier_tests {
4004 use super::*;
4005 use serde_json::json;
4006
4007 fn trust_with(handle: &str, tier: &str) -> Value {
4008 json!({
4009 "version": 1,
4010 "agents": {
4011 handle: {
4012 "tier": tier,
4013 "did": format!("did:wire:{handle}"),
4014 "card": {"capabilities": ["wire/v3.1"]}
4015 }
4016 }
4017 })
4018 }
4019
4020 #[test]
4021 fn pending_ack_when_verified_but_no_slot_token() {
4022 let trust = trust_with("willard", "VERIFIED");
4026 let relay_state = json!({
4027 "peers": {
4028 "willard": {
4029 "relay_url": "https://relay",
4030 "slot_id": "abc",
4031 "slot_token": "",
4032 }
4033 }
4034 });
4035 assert_eq!(
4036 effective_peer_tier(&trust, &relay_state, "willard"),
4037 "PENDING_ACK"
4038 );
4039 }
4040
4041 #[test]
4042 fn verified_when_slot_token_present() {
4043 let trust = trust_with("willard", "VERIFIED");
4044 let relay_state = json!({
4045 "peers": {
4046 "willard": {
4047 "relay_url": "https://relay",
4048 "slot_id": "abc",
4049 "slot_token": "tok123",
4050 }
4051 }
4052 });
4053 assert_eq!(
4054 effective_peer_tier(&trust, &relay_state, "willard"),
4055 "VERIFIED"
4056 );
4057 }
4058
4059 #[test]
4060 fn raw_tier_passes_through_for_non_verified() {
4061 let trust = trust_with("willard", "UNTRUSTED");
4064 let relay_state = json!({
4065 "peers": {"willard": {"slot_token": ""}}
4066 });
4067 assert_eq!(
4068 effective_peer_tier(&trust, &relay_state, "willard"),
4069 "UNTRUSTED"
4070 );
4071 }
4072
4073 #[test]
4074 fn pending_ack_when_relay_state_missing_peer() {
4075 let trust = trust_with("willard", "VERIFIED");
4079 let relay_state = json!({"peers": {}});
4080 assert_eq!(
4081 effective_peer_tier(&trust, &relay_state, "willard"),
4082 "PENDING_ACK"
4083 );
4084 }
4085}
4086
4087#[cfg(test)]
4088mod monitor_tests {
4089 use super::*;
4090 use crate::inbox_watch::InboxEvent;
4091 use serde_json::Value;
4092
4093 fn ev(peer: &str, kind: &str, body: &str) -> InboxEvent {
4094 InboxEvent {
4095 peer: peer.to_string(),
4096 event_id: "abcd1234567890ef".to_string(),
4097 kind: kind.to_string(),
4098 body_preview: body.to_string(),
4099 verified: true,
4100 timestamp: "2026-05-15T23:14:07.123456Z".to_string(),
4101 raw: Value::Null,
4102 }
4103 }
4104
4105 #[test]
4106 fn monitor_filter_drops_handshake_kinds_by_default() {
4107 assert!(monitor_is_noise_kind("pair_drop"));
4112 assert!(monitor_is_noise_kind("pair_drop_ack"));
4113 assert!(monitor_is_noise_kind("heartbeat"));
4114
4115 assert!(!monitor_is_noise_kind("claim"));
4117 assert!(!monitor_is_noise_kind("decision"));
4118 assert!(!monitor_is_noise_kind("ack"));
4119 assert!(!monitor_is_noise_kind("request"));
4120 assert!(!monitor_is_noise_kind("note"));
4121 assert!(!monitor_is_noise_kind("future_kind_we_dont_know"));
4125 }
4126
4127 #[test]
4128 fn monitor_render_plain_is_one_short_line() {
4129 let e = ev("willard", "claim", "real v8 train shipped 1350 steps");
4130 let line = monitor_render(&e, false).unwrap();
4131 assert!(!line.contains('\n'), "render must be one line: {line}");
4133 assert!(line.contains("willard"));
4135 assert!(line.contains("claim"));
4136 assert!(line.contains("real v8 train"));
4137 assert!(line.contains("abcd12345678"));
4139 assert!(
4140 !line.contains("abcd1234567890ef"),
4141 "should truncate full id"
4142 );
4143 assert!(line.contains("2026-05-15T23:14:07"));
4145 }
4146
4147 #[test]
4148 fn monitor_render_strips_newlines_from_body() {
4149 let e = ev("spark", "claim", "line one\nline two\nline three");
4154 let line = monitor_render(&e, false).unwrap();
4155 assert!(!line.contains('\n'), "newlines must be stripped: {line}");
4156 assert!(line.contains("line one line two line three"));
4157 }
4158
4159 #[test]
4160 fn monitor_render_json_is_valid_jsonl() {
4161 let e = ev("spark", "claim", "hi");
4162 let line = monitor_render(&e, true).unwrap();
4163 assert!(!line.contains('\n'));
4164 let parsed: Value = serde_json::from_str(&line).expect("valid JSONL");
4165 assert_eq!(parsed["peer"], "spark");
4166 assert_eq!(parsed["kind"], "claim");
4167 assert_eq!(parsed["body_preview"], "hi");
4168 }
4169
4170 #[test]
4171 fn monitor_does_not_drop_on_verified_null() {
4172 let mut e = ev("spark", "claim", "from disk with verified=null");
4183 e.verified = false; let line = monitor_render(&e, false).unwrap();
4185 assert!(line.contains("from disk with verified=null"));
4186 assert!(!monitor_is_noise_kind("claim"));
4188 }
4189}
4190
4191fn cmd_verify(path: &str, as_json: bool) -> Result<()> {
4194 let body = if path == "-" {
4195 let mut buf = String::new();
4196 use std::io::Read;
4197 std::io::stdin().read_to_string(&mut buf)?;
4198 buf
4199 } else {
4200 std::fs::read_to_string(path).with_context(|| format!("reading {path}"))?
4201 };
4202 let event: Value = serde_json::from_str(&body)?;
4203 let trust = config::read_trust()?;
4204 match verify_message_v31(&event, &trust) {
4205 Ok(()) => {
4206 if as_json {
4207 println!("{}", serde_json::to_string(&json!({"verified": true}))?);
4208 } else {
4209 println!("verified ✓");
4210 }
4211 Ok(())
4212 }
4213 Err(e) => {
4214 let reason = e.to_string();
4215 if as_json {
4216 println!(
4217 "{}",
4218 serde_json::to_string(&json!({"verified": false, "reason": reason}))?
4219 );
4220 } else {
4221 eprintln!("FAILED: {reason}");
4222 }
4223 std::process::exit(1);
4224 }
4225 }
4226}
4227
4228fn cmd_mcp() -> Result<()> {
4231 crate::mcp::run()
4232}
4233
4234fn cmd_relay_server(bind: &str, local_only: bool, uds: Option<&std::path::Path>) -> Result<()> {
4235 if let Some(socket_path) = uds {
4240 let base = if let Ok(home) = std::env::var("WIRE_HOME") {
4241 std::path::PathBuf::from(home)
4242 .join("state")
4243 .join("wire-relay")
4244 .join("uds")
4245 } else {
4246 dirs::state_dir()
4247 .or_else(dirs::data_local_dir)
4248 .ok_or_else(|| anyhow::anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?
4249 .join("wire-relay")
4250 .join("uds")
4251 };
4252 let runtime = tokio::runtime::Builder::new_multi_thread()
4253 .enable_all()
4254 .build()?;
4255 return runtime.block_on(crate::relay_server::serve_uds(
4256 socket_path.to_path_buf(),
4257 base,
4258 ));
4259 }
4260 if local_only {
4264 validate_loopback_bind(bind)?;
4265 }
4266 let base = if let Ok(home) = std::env::var("WIRE_HOME") {
4272 std::path::PathBuf::from(home)
4273 .join("state")
4274 .join("wire-relay")
4275 } else {
4276 dirs::state_dir()
4277 .or_else(dirs::data_local_dir)
4278 .ok_or_else(|| anyhow::anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?
4279 .join("wire-relay")
4280 };
4281 let state_dir = if local_only { base.join("local") } else { base };
4282 let runtime = tokio::runtime::Builder::new_multi_thread()
4283 .enable_all()
4284 .build()?;
4285 runtime.block_on(crate::relay_server::serve_with_mode(
4286 bind,
4287 state_dir,
4288 crate::relay_server::ServerMode { local_only },
4289 ))
4290}
4291
4292fn validate_loopback_bind(bind: &str) -> Result<()> {
4310 let host = if let Some(stripped) = bind.strip_prefix('[') {
4312 let close = stripped
4313 .find(']')
4314 .ok_or_else(|| anyhow::anyhow!("malformed IPv6 bind {bind:?}"))?;
4315 stripped[..close].to_string()
4316 } else {
4317 bind.rsplit_once(':')
4318 .map(|(h, _)| h.to_string())
4319 .unwrap_or_else(|| bind.to_string())
4320 };
4321 use std::net::{IpAddr, ToSocketAddrs};
4322 let probe = format!("{host}:0");
4323 let resolved: Vec<_> = probe
4324 .to_socket_addrs()
4325 .with_context(|| format!("resolving bind host {host:?}"))?
4326 .collect();
4327 if resolved.is_empty() {
4328 bail!("--local-only: bind host {host:?} resolved to no addresses");
4329 }
4330 for addr in &resolved {
4331 let ip = addr.ip();
4332 let is_acceptable = match ip {
4333 IpAddr::V4(v4) => {
4334 v4.is_loopback() || v4.is_private() || {
4335 let octets = v4.octets();
4337 octets[0] == 100 && (64..=127).contains(&octets[1])
4338 }
4339 }
4340 IpAddr::V6(v6) => v6.is_loopback(), };
4342 if !is_acceptable {
4343 bail!(
4344 "--local-only refuses non-private bind: {host:?} resolves to {} \
4345 which is not loopback (127/8, ::1), RFC 1918 private \
4346 (10/8, 172.16/12, 192.168/16), or RFC 6598 CGNAT/Tailscale \
4347 (100.64.0.0/10). Remove --local-only to bind publicly.",
4348 ip
4349 );
4350 }
4351 }
4352 Ok(())
4353}
4354
4355fn parse_scope(s: &str) -> Result<crate::endpoints::EndpointScope> {
4358 use crate::endpoints::EndpointScope;
4359 match s.to_lowercase().as_str() {
4360 "federation" | "fed" => Ok(EndpointScope::Federation),
4361 "local" => Ok(EndpointScope::Local),
4362 "lan" => Ok(EndpointScope::Lan),
4363 "uds" => Ok(EndpointScope::Uds),
4364 other => bail!("unknown --scope `{other}` (expected federation|local|lan|uds)"),
4365 }
4366}
4367
4368fn cmd_bind_relay(
4374 url: &str,
4375 scope: Option<&str>,
4376 replace: bool,
4377 migrate_pinned: bool,
4378 as_json: bool,
4379) -> Result<()> {
4380 use crate::endpoints::{Endpoint, self_endpoints};
4381
4382 if !config::is_initialized()? {
4383 bail!("not initialized — run `wire init <handle>` first");
4384 }
4385 let card = config::read_agent_card()?;
4386 let did = card.get("did").and_then(Value::as_str).unwrap_or("");
4387 let handle = crate::agent_card::display_handle_from_did(did).to_string();
4388
4389 let normalized = url.trim_end_matches('/');
4390 let new_scope = match scope {
4391 Some(s) => parse_scope(s)?,
4392 None => crate::endpoints::infer_scope_from_url(normalized),
4393 };
4394
4395 let existing = config::read_relay_state().unwrap_or_else(|_| json!({}));
4396 let pinned: Vec<String> = existing
4397 .get("peers")
4398 .and_then(|p| p.as_object())
4399 .map(|o| o.keys().cloned().collect())
4400 .unwrap_or_default();
4401
4402 let existing_eps = self_endpoints(&existing);
4403 let is_rebind_same = existing_eps.iter().any(|e| e.relay_url == normalized);
4404
4405 let destructive = replace || is_rebind_same;
4412 if destructive && !pinned.is_empty() && !migrate_pinned {
4413 let list = pinned.join(", ");
4414 let why = if replace {
4415 "`--replace` drops your other slot(s)"
4416 } else {
4417 "re-binding the same relay rotates its slot"
4418 };
4419 bail!(
4420 "bind-relay would black-hole {n} pinned peer(s): {list}. {why}; they are \
4421 pinned to your CURRENT slot and would keep pushing to a slot you no longer \
4422 read.\n\n\
4423 SAFE PATHS:\n\
4424 • Default (omit `--replace`) ADDITIVELY binds a NEW relay, keeping existing \
4425 slots — no black-hole.\n\
4426 • `wire rotate-slot` — same-relay rotation that emits wire_close to peers.\n\
4427 • `wire bind-relay {url} --migrate-pinned` — proceed anyway; re-pair each \
4428 peer out-of-band.\n\n\
4429 Issue #7 (silent black-hole on relay change) caught this.",
4430 n = pinned.len(),
4431 );
4432 }
4433
4434 let client = crate::relay_client::RelayClient::new(normalized);
4435 client.check_healthz()?;
4436 let alloc = client.allocate_slot(Some(&handle))?;
4437
4438 if destructive && !pinned.is_empty() {
4439 eprintln!(
4440 "wire bind-relay: {mode} with {n} pinned peer(s) — they will black-hole \
4441 until they re-pin: {peers}",
4442 mode = if replace { "replacing" } else { "rotating" },
4443 n = pinned.len(),
4444 peers = pinned.join(", "),
4445 );
4446 }
4447
4448 let mut state = existing;
4452 if replace {
4453 state["self"] = Value::Null;
4454 }
4455 crate::endpoints::upsert_self_endpoint(
4456 &mut state,
4457 Endpoint {
4458 relay_url: normalized.to_string(),
4459 slot_id: alloc.slot_id.clone(),
4460 slot_token: alloc.slot_token.clone(),
4461 scope: new_scope,
4462 },
4463 );
4464 config::write_relay_state(&state)?;
4465 let eps = self_endpoints(&state);
4466
4467 let scope_str = format!("{new_scope:?}").to_lowercase();
4468 if as_json {
4469 println!(
4470 "{}",
4471 serde_json::to_string(&json!({
4472 "relay_url": normalized,
4473 "slot_id": alloc.slot_id,
4474 "scope": scope_str,
4475 "endpoints": eps.len(),
4476 "additive": !replace,
4477 "slot_token_present": true,
4478 }))?
4479 );
4480 } else {
4481 println!(
4482 "bound {scope_str} slot on {normalized} (slot {})",
4483 alloc.slot_id
4484 );
4485 println!(
4486 "self now has {n} endpoint(s): {list}",
4487 n = eps.len(),
4488 list = eps
4489 .iter()
4490 .map(|e| format!("{}({:?})", e.relay_url, e.scope))
4491 .collect::<Vec<_>>()
4492 .join(", "),
4493 );
4494 }
4495 Ok(())
4496}
4497
4498fn cmd_add_peer_slot(
4501 handle: &str,
4502 url: &str,
4503 slot_id: &str,
4504 slot_token: &str,
4505 as_json: bool,
4506) -> Result<()> {
4507 use crate::endpoints::{Endpoint, infer_scope_from_url, pin_peer_endpoints};
4508 let mut state = config::read_relay_state()?;
4509
4510 let new_ep = Endpoint {
4517 relay_url: url.to_string(),
4518 slot_id: slot_id.to_string(),
4519 slot_token: slot_token.to_string(),
4520 scope: infer_scope_from_url(url),
4521 };
4522 let mut endpoints: Vec<Endpoint> = state
4523 .get("peers")
4524 .and_then(|p| p.get(handle))
4525 .and_then(|e| e.get("endpoints"))
4526 .and_then(|a| serde_json::from_value::<Vec<Endpoint>>(a.clone()).ok())
4527 .unwrap_or_default();
4528 if endpoints.is_empty()
4530 && let Some(peer) = state.get("peers").and_then(|p| p.get(handle))
4531 && let (Some(ru), Some(si), Some(st)) = (
4532 peer.get("relay_url").and_then(Value::as_str),
4533 peer.get("slot_id").and_then(Value::as_str),
4534 peer.get("slot_token").and_then(Value::as_str),
4535 )
4536 {
4537 endpoints.push(Endpoint {
4538 relay_url: ru.to_string(),
4539 slot_id: si.to_string(),
4540 slot_token: st.to_string(),
4541 scope: infer_scope_from_url(ru),
4542 });
4543 }
4544 if let Some(existing) = endpoints
4546 .iter_mut()
4547 .find(|e| e.relay_url == new_ep.relay_url)
4548 {
4549 *existing = new_ep;
4550 } else {
4551 endpoints.push(new_ep);
4552 }
4553 let n = endpoints.len();
4554 pin_peer_endpoints(&mut state, handle, &endpoints)?;
4555 config::write_relay_state(&state)?;
4556 if as_json {
4557 println!(
4558 "{}",
4559 serde_json::to_string(&json!({
4560 "handle": handle,
4561 "relay_url": url,
4562 "slot_id": slot_id,
4563 "added": true,
4564 "endpoint_count": n,
4565 }))?
4566 );
4567 } else {
4568 println!(
4569 "pinned peer slot for {handle} at {url} ({slot_id}) — peer now has {n} endpoint(s)"
4570 );
4571 }
4572 Ok(())
4573}
4574
4575fn cmd_push(peer_filter: Option<&str>, as_json: bool) -> Result<()> {
4578 let state = config::read_relay_state()?;
4579 let peers = state["peers"].as_object().cloned().unwrap_or_default();
4580 if peers.is_empty() {
4581 bail!(
4582 "no peer slots pinned — run `wire add-peer-slot <handle> <url> <slot_id> <token>` first"
4583 );
4584 }
4585 let outbox_dir = config::outbox_dir()?;
4586 if outbox_dir.exists() {
4591 let pinned: std::collections::HashSet<String> = peers.keys().cloned().collect();
4592 for entry in std::fs::read_dir(&outbox_dir)?.flatten() {
4593 let path = entry.path();
4594 if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
4595 continue;
4596 }
4597 let stem = match path.file_stem().and_then(|s| s.to_str()) {
4598 Some(s) => s.to_string(),
4599 None => continue,
4600 };
4601 if pinned.contains(&stem) {
4602 continue;
4603 }
4604 let bare = crate::agent_card::bare_handle(&stem);
4607 if pinned.contains(bare) {
4608 eprintln!(
4609 "wire push: WARN stale outbox file `{}.jsonl` not enumerated (pinned peer is `{bare}`). \
4610 Merge with: `cat {} >> {}` then delete the FQDN file.",
4611 stem,
4612 path.display(),
4613 outbox_dir.join(format!("{bare}.jsonl")).display(),
4614 );
4615 }
4616 }
4617 }
4618 if !outbox_dir.exists() {
4619 if as_json {
4620 println!(
4621 "{}",
4622 serde_json::to_string(&json!({"pushed": [], "skipped": []}))?
4623 );
4624 } else {
4625 println!("phyllis: nothing to dial out — write a message first with `wire send`");
4626 }
4627 return Ok(());
4628 }
4629
4630 let mut pushed = Vec::new();
4631 let mut skipped = Vec::new();
4632
4633 for (peer_handle, _) in peers.iter() {
4639 if let Some(want) = peer_filter
4640 && peer_handle != want
4641 {
4642 continue;
4643 }
4644 let outbox = outbox_dir.join(format!("{peer_handle}.jsonl"));
4645 if !outbox.exists() {
4646 continue;
4647 }
4648 let ordered_endpoints =
4649 crate::endpoints::peer_endpoints_in_priority_order(&state, peer_handle);
4650 if ordered_endpoints.is_empty() {
4651 for line in std::fs::read_to_string(&outbox).unwrap_or_default().lines() {
4655 let event: Value = match serde_json::from_str(line) {
4656 Ok(v) => v,
4657 Err(_) => continue,
4658 };
4659 let event_id = event
4660 .get("event_id")
4661 .and_then(Value::as_str)
4662 .unwrap_or("")
4663 .to_string();
4664 skipped.push(json!({
4665 "peer": peer_handle,
4666 "event_id": event_id,
4667 "reason": "no reachable endpoint pinned for peer",
4668 }));
4669 }
4670 continue;
4671 }
4672 let body = std::fs::read_to_string(&outbox)?;
4673 for line in body.lines() {
4674 let event: Value = match serde_json::from_str(line) {
4675 Ok(v) => v,
4676 Err(_) => continue,
4677 };
4678 let event_id = event
4679 .get("event_id")
4680 .and_then(Value::as_str)
4681 .unwrap_or("")
4682 .to_string();
4683
4684 let mut delivered = false;
4685 let mut last_err_reason: Option<String> = None;
4686 for endpoint in &ordered_endpoints {
4687 let client = crate::relay_client::RelayClient::new(&endpoint.relay_url);
4688 match client.post_event(&endpoint.slot_id, &endpoint.slot_token, &event) {
4689 Ok(resp) => {
4690 if resp.status == "duplicate" {
4691 skipped.push(json!({
4692 "peer": peer_handle,
4693 "event_id": event_id,
4694 "reason": "duplicate",
4695 "endpoint": endpoint.relay_url,
4696 "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
4697 }));
4698 } else {
4699 pushed.push(json!({
4700 "peer": peer_handle,
4701 "event_id": event_id,
4702 "endpoint": endpoint.relay_url,
4703 "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
4704 }));
4705 }
4706 delivered = true;
4707 break;
4708 }
4709 Err(e) => {
4710 last_err_reason = Some(crate::relay_client::format_transport_error(&e));
4715 }
4716 }
4717 }
4718 if !delivered {
4719 skipped.push(json!({
4720 "peer": peer_handle,
4721 "event_id": event_id,
4722 "reason": last_err_reason.unwrap_or_else(|| "all endpoints failed".to_string()),
4723 }));
4724 }
4725 }
4726 }
4727
4728 if as_json {
4729 println!(
4730 "{}",
4731 serde_json::to_string(&json!({"pushed": pushed, "skipped": skipped}))?
4732 );
4733 } else {
4734 println!(
4735 "pushed {} event(s); skipped {} ({})",
4736 pushed.len(),
4737 skipped.len(),
4738 if skipped.is_empty() {
4739 "none"
4740 } else {
4741 "see --json for detail"
4742 }
4743 );
4744 }
4745 Ok(())
4746}
4747
4748fn cmd_pull(as_json: bool) -> Result<()> {
4751 let state = config::read_relay_state()?;
4752 let self_state = state.get("self").cloned().unwrap_or(Value::Null);
4753 if self_state.is_null() {
4754 bail!("self slot not bound — run `wire bind-relay <url>` first");
4755 }
4756
4757 let endpoints = crate::endpoints::self_endpoints(&state);
4766 if endpoints.is_empty() {
4767 bail!("self.relay_url / slot_id / slot_token missing in relay_state.json");
4768 }
4769
4770 let inbox_dir = config::inbox_dir()?;
4771 config::ensure_dirs()?;
4772
4773 let mut total_seen = 0usize;
4774 let mut all_written: Vec<Value> = Vec::new();
4775 let mut all_rejected: Vec<Value> = Vec::new();
4776 let mut all_blocked = false;
4777 let mut all_advance_cursor_to: Option<String> = None;
4778
4779 for endpoint in &endpoints {
4780 let cursor_key = endpoint_cursor_key(endpoint.scope);
4781 let last_event_id = self_state
4782 .get(&cursor_key)
4783 .and_then(Value::as_str)
4784 .map(str::to_string);
4785 let client = crate::relay_client::RelayClient::new(&endpoint.relay_url);
4786 let events = match client.list_events(
4787 &endpoint.slot_id,
4788 &endpoint.slot_token,
4789 last_event_id.as_deref(),
4790 Some(1000),
4791 ) {
4792 Ok(ev) => ev,
4793 Err(e) => {
4794 eprintln!(
4798 "wire pull: endpoint {} ({:?}) errored: {}; continuing",
4799 endpoint.relay_url,
4800 endpoint.scope,
4801 crate::relay_client::format_transport_error(&e),
4802 );
4803 continue;
4804 }
4805 };
4806 total_seen += events.len();
4807 let result = crate::pull::process_events(&events, last_event_id.clone(), &inbox_dir)?;
4808 all_written.extend(result.written.iter().cloned());
4809 all_rejected.extend(result.rejected.iter().cloned());
4810 if result.blocked {
4811 all_blocked = true;
4812 }
4813 if let Some(eid) = result.advance_cursor_to.clone() {
4816 if endpoint.scope == crate::endpoints::EndpointScope::Federation {
4817 all_advance_cursor_to = Some(eid.clone());
4818 }
4819 let key = cursor_key.clone();
4820 config::update_relay_state(|state| {
4821 if let Some(self_obj) = state.get_mut("self").and_then(Value::as_object_mut) {
4822 self_obj.insert(key, Value::String(eid));
4823 }
4824 Ok(())
4825 })?;
4826 }
4827 }
4828
4829 let result = crate::pull::PullResult {
4834 written: all_written,
4835 rejected: all_rejected,
4836 blocked: all_blocked,
4837 advance_cursor_to: all_advance_cursor_to,
4838 };
4839 let events_len = total_seen;
4840
4841 if as_json {
4845 println!(
4846 "{}",
4847 serde_json::to_string(&json!({
4848 "written": result.written,
4849 "rejected": result.rejected,
4850 "total_seen": events_len,
4851 "cursor_blocked": result.blocked,
4852 "cursor_advanced_to": result.advance_cursor_to,
4853 }))?
4854 );
4855 } else {
4856 let blocking = result
4857 .rejected
4858 .iter()
4859 .filter(|r| r.get("blocks_cursor").and_then(Value::as_bool) == Some(true))
4860 .count();
4861 if blocking > 0 {
4862 println!(
4863 "pulled {} event(s); wrote {}; rejected {} ({} BLOCKING cursor — see `wire pull --json`)",
4864 events_len,
4865 result.written.len(),
4866 result.rejected.len(),
4867 blocking,
4868 );
4869 } else {
4870 println!(
4871 "pulled {} event(s); wrote {}; rejected {}",
4872 events_len,
4873 result.written.len(),
4874 result.rejected.len(),
4875 );
4876 }
4877 }
4878 Ok(())
4879}
4880
4881fn endpoint_cursor_key(scope: crate::endpoints::EndpointScope) -> String {
4886 match scope {
4887 crate::endpoints::EndpointScope::Federation => "last_pulled_event_id".to_string(),
4888 crate::endpoints::EndpointScope::Local => "last_pulled_event_id_local".to_string(),
4889 crate::endpoints::EndpointScope::Lan => "last_pulled_event_id_lan".to_string(),
4890 crate::endpoints::EndpointScope::Uds => "last_pulled_event_id_uds".to_string(),
4891 }
4892}
4893
4894fn cmd_rotate_slot(no_announce: bool, as_json: bool) -> Result<()> {
4897 if !config::is_initialized()? {
4898 bail!("not initialized — run `wire init <handle>` first");
4899 }
4900 let mut state = config::read_relay_state()?;
4901 let self_state = state.get("self").cloned().unwrap_or(Value::Null);
4902 if self_state.is_null() {
4903 bail!("self slot not bound — run `wire bind-relay <url>` first (nothing to rotate)");
4904 }
4905 let primary = crate::endpoints::self_primary_endpoint(&state)
4909 .ok_or_else(|| anyhow!("self has no resolvable inbound endpoint to rotate"))?;
4910 let url = primary.relay_url.clone();
4911 let old_slot_id = primary.slot_id.clone();
4912 let old_slot_token = primary.slot_token.clone();
4913
4914 let card = config::read_agent_card()?;
4916 let did = card
4917 .get("did")
4918 .and_then(Value::as_str)
4919 .unwrap_or("")
4920 .to_string();
4921 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
4922 let pk_b64 = card
4923 .get("verify_keys")
4924 .and_then(Value::as_object)
4925 .and_then(|m| m.values().next())
4926 .and_then(|v| v.get("key"))
4927 .and_then(Value::as_str)
4928 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?
4929 .to_string();
4930 let pk_bytes = crate::signing::b64decode(&pk_b64)?;
4931 let sk_seed = config::read_private_key()?;
4932
4933 let normalized = url.trim_end_matches('/').to_string();
4935 let client = crate::relay_client::RelayClient::new(&normalized);
4936 client
4937 .check_healthz()
4938 .context("aborting rotation; old slot still valid")?;
4939 let alloc = client.allocate_slot(Some(&handle))?;
4940 let new_slot_id = alloc.slot_id.clone();
4941 let new_slot_token = alloc.slot_token.clone();
4942
4943 let mut announced: Vec<String> = Vec::new();
4950 if !no_announce {
4951 let now = time::OffsetDateTime::now_utc()
4952 .format(&time::format_description::well_known::Rfc3339)
4953 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
4954 let body = json!({
4955 "reason": "operator-initiated slot rotation",
4956 "new_relay_url": url,
4957 "new_slot_id": new_slot_id,
4958 });
4962 let peers = state["peers"].as_object().cloned().unwrap_or_default();
4963 for (peer_handle, _peer_info) in peers.iter() {
4964 let event = json!({
4965 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
4966 "timestamp": now.clone(),
4967 "from": did,
4968 "to": format!("did:wire:{peer_handle}"),
4969 "type": "wire_close",
4970 "kind": 1201,
4971 "body": body.clone(),
4972 });
4973 let signed = match sign_message_v31(&event, &sk_seed, &pk_bytes, &handle) {
4974 Ok(s) => s,
4975 Err(e) => {
4976 eprintln!("warn: could not sign wire_close for {peer_handle}: {e}");
4977 continue;
4978 }
4979 };
4980 let peer_info = match state["peers"].get(peer_handle) {
4985 Some(p) => p.clone(),
4986 None => continue,
4987 };
4988 let peer_url = peer_info["relay_url"].as_str().unwrap_or(&url);
4989 let peer_slot_id = peer_info["slot_id"].as_str().unwrap_or("");
4990 let peer_slot_token = peer_info["slot_token"].as_str().unwrap_or("");
4991 if peer_slot_id.is_empty() || peer_slot_token.is_empty() {
4992 continue;
4993 }
4994 let peer_client = if peer_url == url {
4995 client.clone()
4996 } else {
4997 crate::relay_client::RelayClient::new(peer_url)
4998 };
4999 match peer_client.post_event(peer_slot_id, peer_slot_token, &signed) {
5000 Ok(_) => announced.push(peer_handle.clone()),
5001 Err(e) => eprintln!("warn: announce to {peer_handle} failed: {e}"),
5002 }
5003 }
5004 }
5005
5006 state["self"] = json!({
5008 "relay_url": url,
5009 "slot_id": new_slot_id,
5010 "slot_token": new_slot_token,
5011 });
5012 config::write_relay_state(&state)?;
5013
5014 if as_json {
5015 println!(
5016 "{}",
5017 serde_json::to_string(&json!({
5018 "rotated": true,
5019 "old_slot_id": old_slot_id,
5020 "new_slot_id": new_slot_id,
5021 "relay_url": url,
5022 "announced_to": announced,
5023 }))?
5024 );
5025 } else {
5026 println!("rotated slot on {url}");
5027 println!(
5028 " old slot_id: {old_slot_id} (orphaned — abusive bearer-holders lose their leverage)"
5029 );
5030 println!(" new slot_id: {new_slot_id}");
5031 if !announced.is_empty() {
5032 println!(
5033 " announced wire_close (kind=1201) to: {}",
5034 announced.join(", ")
5035 );
5036 }
5037 println!();
5038 println!("next steps:");
5039 println!(" - peers see the wire_close event in their next `wire pull`");
5040 println!(
5041 " - paired peers must re-issue: tell them to run `wire add-peer-slot {handle} {url} {new_slot_id} <new-token>`"
5042 );
5043 println!(" (or full re-pair via `wire pair-host`/`wire join`)");
5044 println!(" - until they do, you'll receive but they won't be able to reach you");
5045 let _ = old_slot_token;
5047 }
5048 Ok(())
5049}
5050
5051fn cmd_forget_peer(handle: &str, purge: bool, as_json: bool) -> Result<()> {
5054 let mut trust = config::read_trust()?;
5055 let mut removed_from_trust = false;
5056 if let Some(agents) = trust.get_mut("agents").and_then(Value::as_object_mut)
5057 && agents.remove(handle).is_some()
5058 {
5059 removed_from_trust = true;
5060 }
5061 config::write_trust(&trust)?;
5062
5063 let mut state = config::read_relay_state()?;
5064 let mut removed_from_relay = false;
5065 if let Some(peers) = state.get_mut("peers").and_then(Value::as_object_mut)
5066 && peers.remove(handle).is_some()
5067 {
5068 removed_from_relay = true;
5069 }
5070 config::write_relay_state(&state)?;
5071
5072 let mut purged: Vec<String> = Vec::new();
5073 if purge {
5074 for dir in [config::inbox_dir()?, config::outbox_dir()?] {
5075 let path = dir.join(format!("{handle}.jsonl"));
5076 if path.exists() {
5077 std::fs::remove_file(&path).with_context(|| format!("removing {path:?}"))?;
5078 purged.push(path.to_string_lossy().into());
5079 }
5080 }
5081 }
5082
5083 if !removed_from_trust && !removed_from_relay {
5084 if as_json {
5085 println!(
5086 "{}",
5087 serde_json::to_string(&json!({
5088 "removed": false,
5089 "reason": format!("peer {handle:?} not pinned"),
5090 }))?
5091 );
5092 } else {
5093 eprintln!("peer {handle:?} not found in trust or relay state — nothing to forget");
5094 }
5095 return Ok(());
5096 }
5097
5098 if as_json {
5099 println!(
5100 "{}",
5101 serde_json::to_string(&json!({
5102 "handle": handle,
5103 "removed_from_trust": removed_from_trust,
5104 "removed_from_relay_state": removed_from_relay,
5105 "purged_files": purged,
5106 }))?
5107 );
5108 } else {
5109 println!("forgot peer {handle:?}");
5110 if removed_from_trust {
5111 println!(" - removed from trust.json");
5112 }
5113 if removed_from_relay {
5114 println!(" - removed from relay.json");
5115 }
5116 if !purged.is_empty() {
5117 for p in &purged {
5118 println!(" - deleted {p}");
5119 }
5120 } else if !purge {
5121 println!(" (inbox/outbox files preserved; pass --purge to delete them)");
5122 }
5123 }
5124 Ok(())
5125}
5126
5127fn cmd_daemon(interval_secs: u64, once: bool, as_json: bool) -> Result<()> {
5130 if !config::is_initialized()? {
5131 bail!("not initialized — run `wire init <handle>` first");
5132 }
5133 let interval = std::time::Duration::from_secs(interval_secs.max(1));
5134
5135 if !as_json {
5136 if once {
5137 eprintln!("wire daemon: single sync cycle, then exit");
5138 } else {
5139 eprintln!("wire daemon: syncing every {interval_secs}s. SIGINT to stop.");
5140 }
5141 }
5142
5143 if let Err(e) = crate::pending_pair::cleanup_on_startup() {
5147 eprintln!("daemon: pending-pair cleanup_on_startup error: {e:#}");
5148 }
5149
5150 let (wake_tx, wake_rx) = std::sync::mpsc::channel::<()>();
5156 if !once {
5157 crate::daemon_stream::spawn_stream_subscriber(wake_tx);
5158 }
5159
5160 loop {
5161 let pushed = run_sync_push().unwrap_or_else(|e| {
5162 eprintln!("daemon: push error: {e:#}");
5163 json!({"pushed": [], "skipped": [{"error": e.to_string()}]})
5164 });
5165 let pulled = run_sync_pull().unwrap_or_else(|e| {
5166 eprintln!("daemon: pull error: {e:#}");
5167 json!({"written": [], "rejected": [], "total_seen": 0, "error": e.to_string()})
5168 });
5169 let pairs = crate::pending_pair::tick().unwrap_or_else(|e| {
5170 eprintln!("daemon: pending-pair tick error: {e:#}");
5171 json!({"transitions": []})
5172 });
5173
5174 if as_json {
5175 println!(
5176 "{}",
5177 serde_json::to_string(&json!({
5178 "ts": time::OffsetDateTime::now_utc()
5179 .format(&time::format_description::well_known::Rfc3339)
5180 .unwrap_or_default(),
5181 "push": pushed,
5182 "pull": pulled,
5183 "pairs": pairs,
5184 }))?
5185 );
5186 } else {
5187 let pushed_n = pushed["pushed"].as_array().map(|a| a.len()).unwrap_or(0);
5188 let written_n = pulled["written"].as_array().map(|a| a.len()).unwrap_or(0);
5189 let rejected_n = pulled["rejected"].as_array().map(|a| a.len()).unwrap_or(0);
5190 let pair_transitions = pairs["transitions"]
5191 .as_array()
5192 .map(|a| a.len())
5193 .unwrap_or(0);
5194 if pushed_n > 0 || written_n > 0 || rejected_n > 0 || pair_transitions > 0 {
5195 eprintln!(
5196 "daemon: pushed={pushed_n} pulled={written_n} rejected={rejected_n} pair-transitions={pair_transitions}"
5197 );
5198 }
5199 if let Some(arr) = pairs["transitions"].as_array() {
5201 for t in arr {
5202 eprintln!(
5203 " pair {} : {} → {}",
5204 t.get("code").and_then(Value::as_str).unwrap_or("?"),
5205 t.get("from").and_then(Value::as_str).unwrap_or("?"),
5206 t.get("to").and_then(Value::as_str).unwrap_or("?")
5207 );
5208 if let Some(sas) = t.get("sas").and_then(Value::as_str)
5209 && t.get("to").and_then(Value::as_str) == Some("sas_ready")
5210 {
5211 eprintln!(" SAS digits: {}-{}", &sas[..3], &sas[3..]);
5212 eprintln!(
5213 " Run: wire pair-confirm {} {}",
5214 t.get("code").and_then(Value::as_str).unwrap_or("?"),
5215 sas
5216 );
5217 }
5218 }
5219 }
5220 }
5221
5222 if once {
5223 return Ok(());
5224 }
5225 match wake_rx.recv_timeout(interval) {
5238 Ok(()) | Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
5239 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
5240 std::thread::sleep(interval);
5241 }
5242 }
5243 while wake_rx.try_recv().is_ok() {}
5244 }
5245}
5246
5247fn run_sync_push() -> Result<Value> {
5250 let state = config::read_relay_state()?;
5251 let peers = state["peers"].as_object().cloned().unwrap_or_default();
5252 if peers.is_empty() {
5253 return Ok(json!({"pushed": [], "skipped": []}));
5254 }
5255 let outbox_dir = config::outbox_dir()?;
5256 if !outbox_dir.exists() {
5257 return Ok(json!({"pushed": [], "skipped": []}));
5258 }
5259 let mut pushed = Vec::new();
5260 let mut skipped = Vec::new();
5261 for (peer_handle, slot_info) in peers.iter() {
5262 let outbox = outbox_dir.join(format!("{peer_handle}.jsonl"));
5263 if !outbox.exists() {
5264 continue;
5265 }
5266 let url = slot_info["relay_url"].as_str().unwrap_or("");
5267 let slot_id = slot_info["slot_id"].as_str().unwrap_or("");
5268 let slot_token = slot_info["slot_token"].as_str().unwrap_or("");
5269 if url.is_empty() || slot_id.is_empty() || slot_token.is_empty() {
5270 continue;
5271 }
5272 let client = crate::relay_client::RelayClient::new(url);
5273 let body = std::fs::read_to_string(&outbox)?;
5274 for line in body.lines() {
5275 let event: Value = match serde_json::from_str(line) {
5276 Ok(v) => v,
5277 Err(_) => continue,
5278 };
5279 let event_id = event
5280 .get("event_id")
5281 .and_then(Value::as_str)
5282 .unwrap_or("")
5283 .to_string();
5284 match client.post_event(slot_id, slot_token, &event) {
5285 Ok(resp) => {
5286 if resp.status == "duplicate" {
5287 skipped.push(json!({"peer": peer_handle, "event_id": event_id, "reason": "duplicate"}));
5288 } else {
5289 pushed.push(json!({"peer": peer_handle, "event_id": event_id}));
5290 }
5291 }
5292 Err(e) => {
5293 let reason = crate::relay_client::format_transport_error(&e);
5297 skipped
5298 .push(json!({"peer": peer_handle, "event_id": event_id, "reason": reason}));
5299 }
5300 }
5301 }
5302 }
5303 Ok(json!({"pushed": pushed, "skipped": skipped}))
5304}
5305
5306fn run_sync_pull() -> Result<Value> {
5314 let state = config::read_relay_state()?;
5315 if state.get("self").map(Value::is_null).unwrap_or(true) {
5316 return Ok(json!({"written": [], "rejected": [], "total_seen": 0}));
5317 }
5318 let endpoints = crate::endpoints::self_endpoints(&state);
5325 if endpoints.is_empty() {
5326 return Ok(json!({"written": [], "rejected": [], "total_seen": 0}));
5327 }
5328 let inbox_dir = config::inbox_dir()?;
5329 config::ensure_dirs()?;
5330
5331 let self_obj = state.get("self").cloned().unwrap_or(Value::Null);
5336 let legacy_cursor = self_obj
5337 .get("last_pulled_event_id")
5338 .and_then(Value::as_str)
5339 .map(str::to_string);
5340 let primary_slot = crate::endpoints::self_primary_endpoint(&state).map(|e| e.slot_id);
5341 let mut cursors: serde_json::Map<String, Value> = self_obj
5342 .get("cursors")
5343 .and_then(Value::as_object)
5344 .cloned()
5345 .unwrap_or_default();
5346
5347 let mut all_written: Vec<Value> = Vec::new();
5348 let mut all_rejected: Vec<Value> = Vec::new();
5349 let mut total_seen = 0usize;
5350 let mut blocked_any = false;
5351
5352 for ep in &endpoints {
5353 if ep.relay_url.is_empty() {
5354 continue;
5355 }
5356 let cursor = cursors
5357 .get(&ep.slot_id)
5358 .and_then(Value::as_str)
5359 .map(str::to_string)
5360 .or_else(|| {
5361 if Some(&ep.slot_id) == primary_slot.as_ref() {
5362 legacy_cursor.clone()
5363 } else {
5364 None
5365 }
5366 });
5367 let client = crate::relay_client::RelayClient::new(&ep.relay_url);
5368 let events =
5371 match client.list_events(&ep.slot_id, &ep.slot_token, cursor.as_deref(), Some(1000)) {
5372 Ok(e) => e,
5373 Err(e) => {
5374 eprintln!(
5375 "daemon: pull error on {} slot {} (continuing): {e:#}",
5376 ep.relay_url, ep.slot_id
5377 );
5378 continue;
5379 }
5380 };
5381 total_seen += events.len();
5382 let result = crate::pull::process_events(&events, cursor, &inbox_dir)?;
5385 if let Some(eid) = &result.advance_cursor_to {
5386 cursors.insert(ep.slot_id.clone(), Value::String(eid.clone()));
5387 }
5388 blocked_any |= result.blocked;
5389 all_written.extend(result.written);
5390 all_rejected.extend(result.rejected);
5391 }
5392
5393 let primary_cursor = primary_slot
5397 .as_ref()
5398 .and_then(|s| cursors.get(s))
5399 .and_then(Value::as_str)
5400 .map(str::to_string);
5401 config::update_relay_state(|state| {
5402 if let Some(self_obj) = state.get_mut("self").and_then(Value::as_object_mut) {
5403 self_obj.insert("cursors".into(), Value::Object(cursors.clone()));
5404 if let Some(pc) = &primary_cursor {
5405 self_obj.insert("last_pulled_event_id".into(), Value::String(pc.clone()));
5406 }
5407 }
5408 Ok(())
5409 })?;
5410
5411 Ok(json!({
5412 "written": all_written,
5413 "rejected": all_rejected,
5414 "total_seen": total_seen,
5415 "cursor_blocked": blocked_any,
5416 "endpoints_pulled": endpoints.len(),
5417 }))
5418}
5419
5420fn cmd_pin(card_file: &str, as_json: bool) -> Result<()> {
5423 let body =
5424 std::fs::read_to_string(card_file).with_context(|| format!("reading {card_file}"))?;
5425 let card: Value =
5426 serde_json::from_str(&body).with_context(|| format!("parsing {card_file}"))?;
5427 crate::agent_card::verify_agent_card(&card)
5428 .map_err(|e| anyhow!("peer card signature invalid: {e}"))?;
5429
5430 let mut trust = config::read_trust()?;
5431 crate::trust::add_agent_card_pin(&mut trust, &card, Some("VERIFIED"));
5432
5433 let did = card.get("did").and_then(Value::as_str).unwrap_or("");
5434 let handle = crate::agent_card::display_handle_from_did(did).to_string();
5435 config::write_trust(&trust)?;
5436
5437 if as_json {
5438 println!(
5439 "{}",
5440 serde_json::to_string(&json!({
5441 "handle": handle,
5442 "did": did,
5443 "tier": "VERIFIED",
5444 "pinned": true,
5445 }))?
5446 );
5447 } else {
5448 println!("pinned {handle} ({did}) at tier VERIFIED");
5449 }
5450 Ok(())
5451}
5452
5453fn cmd_pair_host(relay_url: &str, auto_yes: bool, timeout_secs: u64) -> Result<()> {
5456 pair_orchestrate(relay_url, None, "host", auto_yes, timeout_secs)
5457}
5458
5459fn cmd_pair_join(
5460 code_phrase: &str,
5461 relay_url: &str,
5462 auto_yes: bool,
5463 timeout_secs: u64,
5464) -> Result<()> {
5465 pair_orchestrate(
5466 relay_url,
5467 Some(code_phrase),
5468 "guest",
5469 auto_yes,
5470 timeout_secs,
5471 )
5472}
5473
5474fn pair_orchestrate(
5480 relay_url: &str,
5481 code_in: Option<&str>,
5482 role: &str,
5483 auto_yes: bool,
5484 timeout_secs: u64,
5485) -> Result<()> {
5486 use crate::pair_session::{pair_session_finalize, pair_session_open, pair_session_try_sas};
5487
5488 let mut s = pair_session_open(role, relay_url, code_in)?;
5489
5490 if role == "host" {
5491 eprintln!();
5492 eprintln!("share this code phrase with your peer:");
5493 eprintln!();
5494 eprintln!(" {}", s.code);
5495 eprintln!();
5496 eprintln!(
5497 "waiting for peer to run `wire pair-join {} --relay {relay_url}` ...",
5498 s.code
5499 );
5500 } else {
5501 eprintln!();
5502 eprintln!("joined pair-slot on {relay_url} — waiting for host's SPAKE2 message ...");
5503 }
5504
5505 const HEARTBEAT_SECS: u64 = 10;
5510 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
5511 let started = std::time::Instant::now();
5512 let mut last_heartbeat = started;
5513 let formatted = loop {
5514 if let Some(sas) = pair_session_try_sas(&mut s)? {
5515 break sas;
5516 }
5517 let now = std::time::Instant::now();
5518 if now >= deadline {
5519 return Err(anyhow!(
5520 "timeout after {timeout_secs}s waiting for peer's SPAKE2 message"
5521 ));
5522 }
5523 if now.duration_since(last_heartbeat).as_secs() >= HEARTBEAT_SECS {
5524 let elapsed = now.duration_since(started).as_secs();
5525 eprintln!(" ... still waiting ({elapsed}s / {timeout_secs}s)");
5526 last_heartbeat = now;
5527 }
5528 std::thread::sleep(std::time::Duration::from_millis(250));
5529 };
5530
5531 eprintln!();
5532 eprintln!("SAS digits (must match peer's terminal):");
5533 eprintln!();
5534 eprintln!(" {formatted}");
5535 eprintln!();
5536
5537 if !auto_yes {
5540 eprint!("does this match your peer's terminal? [y/N]: ");
5541 use std::io::Write;
5542 std::io::stderr().flush().ok();
5543 let mut input = String::new();
5544 std::io::stdin().read_line(&mut input)?;
5545 let trimmed = input.trim().to_lowercase();
5546 if trimmed != "y" && trimmed != "yes" {
5547 bail!("SAS confirmation declined — aborting pairing");
5548 }
5549 }
5550 s.sas_confirmed = true;
5551
5552 let result = pair_session_finalize(&mut s, timeout_secs)?;
5554
5555 let peer_did = result["paired_with"].as_str().unwrap_or("");
5556 let peer_role = if role == "host" { "guest" } else { "host" };
5557 eprintln!("paired with {peer_did} (peer role: {peer_role})");
5558 eprintln!("peer card pinned at tier VERIFIED");
5559 eprintln!(
5560 "peer relay slot saved to {}",
5561 config::relay_state_path()?.display()
5562 );
5563
5564 println!("{}", serde_json::to_string(&result)?);
5565 Ok(())
5566}
5567
5568fn cmd_pair(
5574 handle: &str,
5575 code: Option<&str>,
5576 relay: &str,
5577 auto_yes: bool,
5578 timeout_secs: u64,
5579 no_setup: bool,
5580) -> Result<()> {
5581 let init_result = crate::pair_session::init_self_idempotent(handle, None, None)?;
5584 let did = init_result
5585 .get("did")
5586 .and_then(|v| v.as_str())
5587 .unwrap_or("(unknown)")
5588 .to_string();
5589 let already = init_result
5590 .get("already_initialized")
5591 .and_then(|v| v.as_bool())
5592 .unwrap_or(false);
5593 if already {
5594 println!("(identity {did} already initialized — reusing)");
5595 } else {
5596 println!("initialized {did}");
5597 }
5598 println!();
5599
5600 match code {
5602 None => {
5603 println!("hosting pair on {relay} (no code = host) ...");
5604 cmd_pair_host(relay, auto_yes, timeout_secs)?;
5605 }
5606 Some(c) => {
5607 println!("joining pair with code {c} on {relay} ...");
5608 cmd_pair_join(c, relay, auto_yes, timeout_secs)?;
5609 }
5610 }
5611
5612 if !no_setup {
5614 println!();
5615 println!("registering wire as MCP server in detected client configs ...");
5616 if let Err(e) = cmd_setup(true) {
5617 eprintln!("warn: setup --apply failed: {e}");
5619 eprintln!(" pair succeeded; you can re-run `wire setup --apply` manually.");
5620 }
5621 }
5622
5623 println!();
5624 println!("pair complete. Next steps:");
5625 println!(" wire daemon start # background sync of inbox/outbox vs relay");
5626 println!(" wire send <peer> claim <msg> # send your peer something");
5627 println!(" wire tail # watch incoming events");
5628 Ok(())
5629}
5630
5631fn cmd_pair_detach(handle: &str, code: Option<&str>, relay: &str) -> Result<()> {
5637 let init_result = crate::pair_session::init_self_idempotent(handle, None, None)?;
5638 let did = init_result
5639 .get("did")
5640 .and_then(|v| v.as_str())
5641 .unwrap_or("(unknown)")
5642 .to_string();
5643 let already = init_result
5644 .get("already_initialized")
5645 .and_then(|v| v.as_bool())
5646 .unwrap_or(false);
5647 if already {
5648 println!("(identity {did} already initialized — reusing)");
5649 } else {
5650 println!("initialized {did}");
5651 }
5652 println!();
5653 match code {
5654 None => cmd_pair_host_detach(relay, false),
5655 Some(c) => cmd_pair_join_detach(c, relay, false),
5656 }
5657}
5658
5659fn cmd_pair_host_detach(relay_url: &str, as_json: bool) -> Result<()> {
5660 if !config::is_initialized()? {
5661 bail!("not initialized — run `wire init <handle>` first");
5662 }
5663 let daemon_spawned = match crate::ensure_up::ensure_daemon_running() {
5664 Ok(b) => b,
5665 Err(e) => {
5666 if !as_json {
5667 eprintln!(
5668 "warn: could not auto-start daemon: {e}; pair will queue but not advance"
5669 );
5670 }
5671 false
5672 }
5673 };
5674 let code = crate::sas::generate_code_phrase();
5675 let code_hash = crate::pair_session::derive_code_hash(&code);
5676 let now = time::OffsetDateTime::now_utc()
5677 .format(&time::format_description::well_known::Rfc3339)
5678 .unwrap_or_default();
5679 let p = crate::pending_pair::PendingPair {
5680 code: code.clone(),
5681 code_hash,
5682 role: "host".to_string(),
5683 relay_url: relay_url.to_string(),
5684 status: "request_host".to_string(),
5685 sas: None,
5686 peer_did: None,
5687 created_at: now,
5688 last_error: None,
5689 pair_id: None,
5690 our_slot_id: None,
5691 our_slot_token: None,
5692 spake2_seed_b64: None,
5693 };
5694 crate::pending_pair::write_pending(&p)?;
5695 if as_json {
5696 println!(
5697 "{}",
5698 serde_json::to_string(&json!({
5699 "state": "queued",
5700 "code_phrase": code,
5701 "relay_url": relay_url,
5702 "role": "host",
5703 "daemon_spawned": daemon_spawned,
5704 }))?
5705 );
5706 } else {
5707 if daemon_spawned {
5708 println!("(started wire daemon in background)");
5709 }
5710 println!("detached pair-host queued. Share this code with your peer:\n");
5711 println!(" {code}\n");
5712 println!("Next steps:");
5713 println!(" wire pair-list # check status");
5714 println!(" wire pair-confirm {code} <digits> # when SAS shows up");
5715 println!(" wire pair-cancel {code} # to abort");
5716 }
5717 Ok(())
5718}
5719
5720fn cmd_pair_join_detach(code_phrase: &str, relay_url: &str, as_json: bool) -> Result<()> {
5721 if !config::is_initialized()? {
5722 bail!("not initialized — run `wire init <handle>` first");
5723 }
5724 let daemon_spawned = match crate::ensure_up::ensure_daemon_running() {
5725 Ok(b) => b,
5726 Err(e) => {
5727 if !as_json {
5728 eprintln!(
5729 "warn: could not auto-start daemon: {e}; pair will queue but not advance"
5730 );
5731 }
5732 false
5733 }
5734 };
5735 let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
5736 let code_hash = crate::pair_session::derive_code_hash(&code);
5737 let now = time::OffsetDateTime::now_utc()
5738 .format(&time::format_description::well_known::Rfc3339)
5739 .unwrap_or_default();
5740 let p = crate::pending_pair::PendingPair {
5741 code: code.clone(),
5742 code_hash,
5743 role: "guest".to_string(),
5744 relay_url: relay_url.to_string(),
5745 status: "request_guest".to_string(),
5746 sas: None,
5747 peer_did: None,
5748 created_at: now,
5749 last_error: None,
5750 pair_id: None,
5751 our_slot_id: None,
5752 our_slot_token: None,
5753 spake2_seed_b64: None,
5754 };
5755 crate::pending_pair::write_pending(&p)?;
5756 if as_json {
5757 println!(
5758 "{}",
5759 serde_json::to_string(&json!({
5760 "state": "queued",
5761 "code_phrase": code,
5762 "relay_url": relay_url,
5763 "role": "guest",
5764 "daemon_spawned": daemon_spawned,
5765 }))?
5766 );
5767 } else {
5768 if daemon_spawned {
5769 println!("(started wire daemon in background)");
5770 }
5771 println!("detached pair-join queued for code {code}.");
5772 println!(
5773 "Run `wire pair-list` to watch for SAS, then `wire pair-confirm {code} <digits>`."
5774 );
5775 }
5776 Ok(())
5777}
5778
5779fn cmd_pair_confirm(code_phrase: &str, typed_digits: &str, as_json: bool) -> Result<()> {
5780 let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
5781 let typed: String = typed_digits
5782 .chars()
5783 .filter(|c| c.is_ascii_digit())
5784 .collect();
5785 if typed.len() != 6 {
5786 bail!(
5787 "expected 6 digits (got {} after stripping non-digits)",
5788 typed.len()
5789 );
5790 }
5791 let mut p = crate::pending_pair::read_pending(&code)?
5792 .ok_or_else(|| anyhow!("no pending pair found for code {code}"))?;
5793 if p.status != "sas_ready" {
5794 bail!(
5795 "pair {code} not in sas_ready state (current: {}). Run `wire pair-list` to see what's going on.",
5796 p.status
5797 );
5798 }
5799 let stored = p
5800 .sas
5801 .as_ref()
5802 .ok_or_else(|| anyhow!("pending file has status=sas_ready but no sas field"))?
5803 .clone();
5804 if stored == typed {
5805 p.status = "confirmed".to_string();
5806 crate::pending_pair::write_pending(&p)?;
5807 if as_json {
5808 println!(
5809 "{}",
5810 serde_json::to_string(&json!({
5811 "state": "confirmed",
5812 "code_phrase": code,
5813 }))?
5814 );
5815 } else {
5816 println!("digits match. Daemon will finalize the handshake on its next tick.");
5817 println!("Run `wire peers` after a few seconds to confirm.");
5818 }
5819 } else {
5820 p.status = "aborted".to_string();
5821 p.last_error = Some(format!(
5822 "SAS digit mismatch (typed {typed}, expected {stored})"
5823 ));
5824 let client = crate::relay_client::RelayClient::new(&p.relay_url);
5825 let _ = client.pair_abandon(&p.code_hash);
5826 crate::pending_pair::write_pending(&p)?;
5827 crate::os_notify::toast(
5828 &format!("wire — pair aborted ({})", p.code),
5829 p.last_error.as_deref().unwrap_or("digits mismatch"),
5830 );
5831 if as_json {
5832 println!(
5833 "{}",
5834 serde_json::to_string(&json!({
5835 "state": "aborted",
5836 "code_phrase": code,
5837 "error": "digits mismatch",
5838 }))?
5839 );
5840 }
5841 bail!("digits mismatch — pair aborted. Re-issue with a fresh `wire pair-host --detach`.");
5842 }
5843 Ok(())
5844}
5845
5846fn cmd_pair_list(as_json: bool, watch: bool, watch_interval_secs: u64) -> Result<()> {
5847 if watch {
5848 return cmd_pair_list_watch(watch_interval_secs);
5849 }
5850 let spake2_items = crate::pending_pair::list_pending()?;
5851 let inbound_items = crate::pending_inbound_pair::list_pending_inbound()?;
5852 if as_json {
5853 println!("{}", serde_json::to_string(&spake2_items)?);
5858 return Ok(());
5859 }
5860 if spake2_items.is_empty() && inbound_items.is_empty() {
5861 println!("no pending pair sessions.");
5862 return Ok(());
5863 }
5864 if !inbound_items.is_empty() {
5867 println!("PENDING INBOUND (v0.5.14 zero-paste pair_drop awaiting your accept)");
5868 println!(
5869 "{:<20} {:<35} {:<25} NEXT STEP",
5870 "PEER", "RELAY", "RECEIVED"
5871 );
5872 for p in &inbound_items {
5873 println!(
5874 "{:<20} {:<35} {:<25} `wire pair-accept {peer}` to accept; `wire pair-reject {peer}` to refuse",
5875 p.peer_handle,
5876 p.peer_relay_url,
5877 p.received_at,
5878 peer = p.peer_handle,
5879 );
5880 }
5881 println!();
5882 }
5883 if !spake2_items.is_empty() {
5884 println!("SPAKE2 SESSIONS");
5885 println!(
5886 "{:<15} {:<8} {:<18} {:<10} NOTE",
5887 "CODE", "ROLE", "STATUS", "SAS"
5888 );
5889 for p in spake2_items {
5890 let sas = p
5891 .sas
5892 .as_ref()
5893 .map(|d| format!("{}-{}", &d[..3], &d[3..]))
5894 .unwrap_or_else(|| "—".to_string());
5895 let note = p
5896 .last_error
5897 .as_deref()
5898 .or(p.peer_did.as_deref())
5899 .unwrap_or("");
5900 println!(
5901 "{:<15} {:<8} {:<18} {:<10} {}",
5902 p.code, p.role, p.status, sas, note
5903 );
5904 }
5905 }
5906 Ok(())
5907}
5908
5909fn cmd_pair_list_watch(interval_secs: u64) -> Result<()> {
5921 use std::collections::HashMap;
5922 use std::io::Write;
5923 let interval = std::time::Duration::from_secs(interval_secs.max(1));
5924 let mut prev: HashMap<String, String> = HashMap::new();
5927 {
5928 let items = crate::pending_pair::list_pending()?;
5929 for p in &items {
5930 println!("{}", serde_json::to_string(&p)?);
5931 prev.insert(p.code.clone(), p.status.clone());
5932 }
5933 let _ = std::io::stdout().flush();
5935 }
5936 loop {
5937 std::thread::sleep(interval);
5938 let items = match crate::pending_pair::list_pending() {
5939 Ok(v) => v,
5940 Err(_) => continue,
5941 };
5942 let mut cur: HashMap<String, String> = HashMap::new();
5943 for p in &items {
5944 cur.insert(p.code.clone(), p.status.clone());
5945 match prev.get(&p.code) {
5946 None => {
5947 println!("{}", serde_json::to_string(&p)?);
5949 }
5950 Some(prev_status) if prev_status != &p.status => {
5951 println!("{}", serde_json::to_string(&p)?);
5953 }
5954 _ => {}
5955 }
5956 }
5957 for code in prev.keys() {
5958 if !cur.contains_key(code) {
5959 println!(
5962 "{}",
5963 serde_json::to_string(&json!({
5964 "code": code,
5965 "status": "removed",
5966 "_synthetic": true,
5967 }))?
5968 );
5969 }
5970 }
5971 let _ = std::io::stdout().flush();
5972 prev = cur;
5973 }
5974}
5975
5976fn cmd_pair_watch(
5980 code_phrase: &str,
5981 target_status: &str,
5982 timeout_secs: u64,
5983 as_json: bool,
5984) -> Result<()> {
5985 let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
5986 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
5987 let mut last_seen_status: Option<String> = None;
5988 loop {
5989 let p_opt = crate::pending_pair::read_pending(&code)?;
5990 let now = std::time::Instant::now();
5991 match p_opt {
5992 None => {
5993 if last_seen_status.is_some() {
5997 if as_json {
5998 println!(
5999 "{}",
6000 serde_json::to_string(&json!({"state": "finalized", "code": code}))?
6001 );
6002 } else {
6003 println!("pair {code} finalized (file removed)");
6004 }
6005 return Ok(());
6006 } else {
6007 if as_json {
6008 println!(
6009 "{}",
6010 serde_json::to_string(&json!({"error": "no such pair", "code": code}))?
6011 );
6012 }
6013 std::process::exit(1);
6014 }
6015 }
6016 Some(p) => {
6017 let cur = p.status.clone();
6018 if Some(cur.clone()) != last_seen_status {
6019 if as_json {
6020 println!("{}", serde_json::to_string(&p)?);
6022 }
6023 last_seen_status = Some(cur.clone());
6024 }
6025 if cur == target_status {
6026 if !as_json {
6027 let sas_str = p
6028 .sas
6029 .as_ref()
6030 .map(|s| format!("{}-{}", &s[..3], &s[3..]))
6031 .unwrap_or_else(|| "—".to_string());
6032 println!("pair {code} reached {target_status} (SAS: {sas_str})");
6033 }
6034 return Ok(());
6035 }
6036 if cur == "aborted" || cur == "aborted_restart" {
6037 if !as_json {
6038 let err = p.last_error.as_deref().unwrap_or("(no detail)");
6039 eprintln!("pair {code} {cur}: {err}");
6040 }
6041 std::process::exit(1);
6042 }
6043 }
6044 }
6045 if now >= deadline {
6046 if !as_json {
6047 eprintln!(
6048 "timeout after {timeout_secs}s waiting for pair {code} to reach {target_status}"
6049 );
6050 }
6051 std::process::exit(2);
6052 }
6053 std::thread::sleep(std::time::Duration::from_millis(250));
6054 }
6055}
6056
6057fn cmd_pair_cancel(code_phrase: &str, as_json: bool) -> Result<()> {
6058 let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
6059 let p = crate::pending_pair::read_pending(&code)?
6060 .ok_or_else(|| anyhow!("no pending pair for code {code}"))?;
6061 let client = crate::relay_client::RelayClient::new(&p.relay_url);
6062 let _ = client.pair_abandon(&p.code_hash);
6063 crate::pending_pair::delete_pending(&code)?;
6064 if as_json {
6065 println!(
6066 "{}",
6067 serde_json::to_string(&json!({
6068 "state": "cancelled",
6069 "code_phrase": code,
6070 }))?
6071 );
6072 } else {
6073 println!("cancelled pending pair {code} (relay slot released, file removed).");
6074 }
6075 Ok(())
6076}
6077
6078fn cmd_pair_abandon(code_phrase: &str, relay_url: &str) -> Result<()> {
6081 let code = crate::sas::parse_code_phrase(code_phrase)?;
6084 let code_hash = crate::pair_session::derive_code_hash(code);
6085 let client = crate::relay_client::RelayClient::new(relay_url);
6086 client.pair_abandon(&code_hash)?;
6087 println!("abandoned pair-slot for code {code_phrase} on {relay_url}");
6088 println!("host can now issue a fresh code; guest can re-join.");
6089 Ok(())
6090}
6091
6092fn cmd_invite(relay: &str, ttl: u64, uses: u32, share: bool, as_json: bool) -> Result<()> {
6095 let url = crate::pair_invite::mint_invite(Some(ttl), uses, Some(relay))?;
6096
6097 let share_payload: Option<Value> = if share {
6100 let client = reqwest::blocking::Client::new();
6101 let single_use = if uses == 1 { Some(1u32) } else { None };
6102 let body = json!({
6103 "invite_url": url,
6104 "ttl_seconds": ttl,
6105 "uses": single_use,
6106 });
6107 let endpoint = format!("{}/v1/invite/register", relay.trim_end_matches('/'));
6108 let resp = client.post(&endpoint).json(&body).send()?;
6109 if !resp.status().is_success() {
6110 let code = resp.status();
6111 let txt = resp.text().unwrap_or_default();
6112 bail!("relay {code} on /v1/invite/register: {txt}");
6113 }
6114 let parsed: Value = resp.json()?;
6115 let token = parsed
6116 .get("token")
6117 .and_then(Value::as_str)
6118 .ok_or_else(|| anyhow::anyhow!("relay reply missing token"))?
6119 .to_string();
6120 let share_url = format!("{}/i/{}", relay.trim_end_matches('/'), token);
6121 let curl_line = format!("curl -fsSL {share_url} | sh");
6122 Some(json!({
6123 "token": token,
6124 "share_url": share_url,
6125 "curl": curl_line,
6126 "expires_unix": parsed.get("expires_unix"),
6127 }))
6128 } else {
6129 None
6130 };
6131
6132 if as_json {
6133 let mut out = json!({
6134 "invite_url": url,
6135 "ttl_secs": ttl,
6136 "uses": uses,
6137 "relay": relay,
6138 });
6139 if let Some(s) = &share_payload {
6140 out["share"] = s.clone();
6141 }
6142 println!("{}", serde_json::to_string(&out)?);
6143 } else if let Some(s) = share_payload {
6144 let curl = s.get("curl").and_then(Value::as_str).unwrap_or("");
6145 eprintln!("# One-curl onboarding. Share this single line — installs wire if missing,");
6146 eprintln!("# accepts the invite, pairs both sides. TTL: {ttl}s. Uses: {uses}.");
6147 println!("{curl}");
6148 } else {
6149 eprintln!("# Share this URL with one peer. Pasting it = pair complete on their side.");
6150 eprintln!("# TTL: {ttl}s. Uses: {uses}.");
6151 println!("{url}");
6152 }
6153 Ok(())
6154}
6155
6156fn cmd_accept(url: &str, as_json: bool) -> Result<()> {
6157 let resolved = if url.starts_with("http://") || url.starts_with("https://") {
6161 let sep = if url.contains('?') { '&' } else { '?' };
6162 let resolve_url = format!("{url}{sep}format=url");
6163 let client = reqwest::blocking::Client::new();
6164 let resp = client
6165 .get(&resolve_url)
6166 .send()
6167 .with_context(|| format!("GET {resolve_url}"))?;
6168 if !resp.status().is_success() {
6169 bail!("could not resolve short URL {url} (HTTP {})", resp.status());
6170 }
6171 let body = resp.text().unwrap_or_default().trim().to_string();
6172 if !body.starts_with("wire://pair?") {
6173 bail!(
6174 "short URL {url} did not resolve to a wire:// invite. \
6175 (got: {}{})",
6176 body.chars().take(80).collect::<String>(),
6177 if body.chars().count() > 80 { "…" } else { "" }
6178 );
6179 }
6180 body
6181 } else {
6182 url.to_string()
6183 };
6184
6185 let result = crate::pair_invite::accept_invite(&resolved)?;
6186 if as_json {
6187 println!("{}", serde_json::to_string(&result)?);
6188 } else {
6189 let did = result
6190 .get("paired_with")
6191 .and_then(Value::as_str)
6192 .unwrap_or("?");
6193 println!("paired with {did}");
6194 println!(
6195 "you can now: wire send {} <kind> <body>",
6196 crate::agent_card::display_handle_from_did(did)
6197 );
6198 }
6199 Ok(())
6200}
6201
6202fn cmd_whois(handle: Option<&str>, as_json: bool, relay_override: Option<&str>) -> Result<()> {
6205 if let Some(h) = handle {
6206 let parsed = crate::pair_profile::parse_handle(h)?;
6207 if config::is_initialized()? {
6210 let card = config::read_agent_card()?;
6211 let local_handle = card
6212 .get("profile")
6213 .and_then(|p| p.get("handle"))
6214 .and_then(Value::as_str)
6215 .map(str::to_string);
6216 if local_handle.as_deref() == Some(h) {
6217 return cmd_whois(None, as_json, None);
6218 }
6219 }
6220 let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)?;
6222 if as_json {
6223 println!("{}", serde_json::to_string(&resolved)?);
6224 } else {
6225 print_resolved_profile(&resolved);
6226 }
6227 return Ok(());
6228 }
6229 let card = config::read_agent_card()?;
6230 if as_json {
6231 let profile = card.get("profile").cloned().unwrap_or(Value::Null);
6232 println!(
6233 "{}",
6234 serde_json::to_string(&json!({
6235 "did": card.get("did").cloned().unwrap_or(Value::Null),
6236 "profile": profile,
6237 }))?
6238 );
6239 } else {
6240 print!("{}", crate::pair_profile::render_self_summary()?);
6241 }
6242 Ok(())
6243}
6244
6245fn print_resolved_profile(resolved: &Value) {
6246 let did = resolved.get("did").and_then(Value::as_str).unwrap_or("?");
6247 let nick = resolved.get("nick").and_then(Value::as_str).unwrap_or("?");
6248 let relay = resolved
6249 .get("relay_url")
6250 .and_then(Value::as_str)
6251 .unwrap_or("");
6252 let slot = resolved
6253 .get("slot_id")
6254 .and_then(Value::as_str)
6255 .unwrap_or("");
6256 let profile = resolved
6257 .get("card")
6258 .and_then(|c| c.get("profile"))
6259 .cloned()
6260 .unwrap_or(Value::Null);
6261 println!("{did}");
6262 println!(" nick: {nick}");
6263 if !relay.is_empty() {
6264 println!(" relay_url: {relay}");
6265 }
6266 if !slot.is_empty() {
6267 println!(" slot_id: {slot}");
6268 }
6269 let pick =
6270 |k: &str| -> Option<String> { profile.get(k).and_then(Value::as_str).map(str::to_string) };
6271 if let Some(s) = pick("display_name") {
6272 println!(" display_name: {s}");
6273 }
6274 if let Some(s) = pick("emoji") {
6275 println!(" emoji: {s}");
6276 }
6277 if let Some(s) = pick("motto") {
6278 println!(" motto: {s}");
6279 }
6280 if let Some(arr) = profile.get("vibe").and_then(Value::as_array) {
6281 let joined: Vec<String> = arr
6282 .iter()
6283 .filter_map(|v| v.as_str().map(str::to_string))
6284 .collect();
6285 println!(" vibe: {}", joined.join(", "));
6286 }
6287 if let Some(s) = pick("pronouns") {
6288 println!(" pronouns: {s}");
6289 }
6290}
6291
6292fn host_of_url(url: &str) -> String {
6300 let no_scheme = url
6301 .trim_start_matches("https://")
6302 .trim_start_matches("http://");
6303 no_scheme
6304 .split('/')
6305 .next()
6306 .unwrap_or("")
6307 .split(':')
6308 .next()
6309 .unwrap_or("")
6310 .to_string()
6311}
6312
6313fn is_known_relay_domain(peer_domain: &str, our_relay_url: &str) -> bool {
6317 const KNOWN_GOOD: &[&str] = &["wireup.net", "wire.laulpogan.com"];
6319 let peer_domain = peer_domain.trim().to_ascii_lowercase();
6320 if KNOWN_GOOD.iter().any(|k| *k == peer_domain) {
6321 return true;
6322 }
6323 let our_host = host_of_url(our_relay_url).to_ascii_lowercase();
6326 if !our_host.is_empty() && our_host == peer_domain {
6327 return true;
6328 }
6329 false
6330}
6331
6332fn resolve_local_session<'a>(
6350 sessions: &'a [crate::session::SessionInfo],
6351 input: &str,
6352) -> Result<&'a crate::session::SessionInfo, ResolveError> {
6353 if let Some(s) = sessions.iter().find(|s| s.name == input) {
6356 return Ok(s);
6357 }
6358 let nick_matches: Vec<&crate::session::SessionInfo> = sessions
6359 .iter()
6360 .filter(|s| {
6361 s.character
6362 .as_ref()
6363 .map(|c| c.nickname == input)
6364 .unwrap_or(false)
6365 })
6366 .collect();
6367 match nick_matches.len() {
6368 0 => Err(ResolveError::NotFound),
6369 1 => Ok(nick_matches[0]),
6370 _ => Err(ResolveError::Ambiguous(
6371 nick_matches.iter().map(|s| s.name.clone()).collect(),
6372 )),
6373 }
6374}
6375
6376#[derive(Debug)]
6377enum ResolveError {
6378 NotFound,
6379 Ambiguous(Vec<String>),
6380}
6381
6382fn resolve_peer_handle(input: &str) -> Result<Option<String>, ResolveError> {
6398 let trust = match config::read_trust() {
6399 Ok(t) => t,
6400 Err(_) => return Ok(None),
6401 };
6402 let agents = match trust.get("agents").and_then(|a| a.as_object()) {
6403 Some(a) => a,
6404 None => return Ok(None),
6405 };
6406 if agents.contains_key(input) {
6407 return Ok(Some(input.to_string()));
6408 }
6409 let mut nick_matches: Vec<String> = Vec::new();
6410 for (handle, agent) in agents.iter() {
6411 let character = match agent.get("card") {
6415 Some(card) => crate::character::Character::from_card(card),
6416 None => match agent.get("did").and_then(Value::as_str) {
6417 Some(did) => crate::character::Character::from_did(did),
6418 None => continue,
6419 },
6420 };
6421 if character.nickname == input {
6422 nick_matches.push(handle.clone());
6423 }
6424 }
6425 match nick_matches.len() {
6426 0 => Ok(None),
6427 1 => Ok(Some(nick_matches.into_iter().next().unwrap())),
6428 _ => Err(ResolveError::Ambiguous(nick_matches)),
6429 }
6430}
6431
6432fn cmd_add_local_sister(sister_name: &str, as_json: bool) -> Result<()> {
6433 let sessions = crate::session::list_sessions()?;
6435 let sister = match resolve_local_session(&sessions, sister_name) {
6436 Ok(s) => s,
6437 Err(ResolveError::NotFound) => bail!(
6438 "no sister session named `{sister_name}` (matched by session name or character nickname). \
6439 Run `wire session list` to see what's available."
6440 ),
6441 Err(ResolveError::Ambiguous(candidates)) => bail!(
6442 "nickname `{sister_name}` is ambiguous — matches {} sessions: {}. \
6443 Disambiguate by passing the session name (one of those listed) instead of the nickname.",
6444 candidates.len(),
6445 candidates.join(", ")
6446 ),
6447 };
6448 if sister.name != sister_name {
6451 eprintln!(
6452 "wire add: resolved nickname `{sister_name}` → session `{}`",
6453 sister.name
6454 );
6455 }
6456
6457 let our_card = config::read_agent_card()
6460 .map_err(|_| anyhow!("not initialized — run `wire init <handle>` first"))?;
6461 let our_did = our_card
6462 .get("did")
6463 .and_then(Value::as_str)
6464 .ok_or_else(|| anyhow!("agent-card missing did"))?
6465 .to_string();
6466 if let Some(sister_did) = sister.did.as_deref()
6467 && sister_did == our_did
6468 {
6469 bail!("refusing to add self (`{sister_name}` is this very session)");
6470 }
6471
6472 let sister_card_path = sister
6474 .home_dir
6475 .join("config")
6476 .join("wire")
6477 .join("agent-card.json");
6478 let sister_card: Value = serde_json::from_slice(
6479 &std::fs::read(&sister_card_path)
6480 .with_context(|| format!("reading sister card {sister_card_path:?}"))?,
6481 )
6482 .with_context(|| format!("parsing sister card {sister_card_path:?}"))?;
6483 let sister_relay_state: Value = std::fs::read(
6484 sister
6485 .home_dir
6486 .join("config")
6487 .join("wire")
6488 .join("relay.json"),
6489 )
6490 .ok()
6491 .and_then(|b| serde_json::from_slice(&b).ok())
6492 .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
6493
6494 let sister_did = sister_card
6495 .get("did")
6496 .and_then(Value::as_str)
6497 .ok_or_else(|| anyhow!("sister card missing did"))?
6498 .to_string();
6499 let sister_handle = crate::agent_card::display_handle_from_did(&sister_did).to_string();
6500
6501 let sister_endpoints = crate::endpoints::self_endpoints(&sister_relay_state);
6505 if sister_endpoints.is_empty() {
6506 bail!(
6507 "sister `{sister_name}` has no endpoints in its relay.json — recreate with `wire session new --local-only` or `--with-local`"
6508 );
6509 }
6510 let sister_local = sister_endpoints
6511 .iter()
6512 .find(|e| e.scope == crate::endpoints::EndpointScope::Local);
6513 let delivery_endpoint = match sister_local {
6514 Some(e) => e.clone(),
6515 None => sister_endpoints[0].clone(),
6516 };
6517
6518 let our_relay_state = config::read_relay_state()?;
6524 let our_endpoints = crate::endpoints::self_endpoints(&our_relay_state);
6525 if our_endpoints.is_empty() {
6526 bail!(
6527 "this session has no endpoints — run `wire session new --local-only` or `wire bind-relay` first"
6528 );
6529 }
6530 let our_advertised = our_endpoints
6531 .iter()
6532 .find(|e| e.scope == crate::endpoints::EndpointScope::Federation)
6533 .cloned()
6534 .unwrap_or_else(|| our_endpoints[0].clone());
6535
6536 let mut trust = config::read_trust()?;
6540 crate::trust::add_agent_card_pin(&mut trust, &sister_card, Some("VERIFIED"));
6541 config::write_trust(&trust)?;
6542 let mut relay_state = config::read_relay_state()?;
6543 crate::endpoints::pin_peer_endpoints(&mut relay_state, &sister_handle, &sister_endpoints)?;
6544 config::write_relay_state(&relay_state)?;
6545
6546 let sk_seed = config::read_private_key()?;
6549 let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
6550 let pk_b64 = our_card
6551 .get("verify_keys")
6552 .and_then(Value::as_object)
6553 .and_then(|m| m.values().next())
6554 .and_then(|v| v.get("key"))
6555 .and_then(Value::as_str)
6556 .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
6557 let pk_bytes = crate::signing::b64decode(pk_b64)?;
6558 let now = time::OffsetDateTime::now_utc()
6559 .format(&time::format_description::well_known::Rfc3339)
6560 .unwrap_or_default();
6561 let mut body = json!({
6562 "card": our_card,
6563 "relay_url": our_advertised.relay_url,
6564 "slot_id": our_advertised.slot_id,
6565 "slot_token": our_advertised.slot_token,
6566 });
6567 body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
6568 let event = json!({
6569 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
6570 "timestamp": now,
6571 "from": our_did,
6572 "to": sister_did,
6573 "type": "pair_drop",
6574 "kind": 1100u32,
6575 "body": body,
6576 });
6577 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
6578 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
6579
6580 let client = crate::relay_client::RelayClient::new(&delivery_endpoint.relay_url);
6584 client
6585 .post_event(
6586 &delivery_endpoint.slot_id,
6587 &delivery_endpoint.slot_token,
6588 &signed,
6589 )
6590 .with_context(|| format!("delivering pair_drop to `{sister_name}`'s local slot"))?;
6591
6592 if as_json {
6593 println!(
6594 "{}",
6595 serde_json::to_string(&json!({
6596 "handle": sister_name,
6597 "paired_with": sister_did,
6598 "peer_handle": sister_handle,
6599 "event_id": event_id,
6600 "delivered_via": match delivery_endpoint.scope {
6601 crate::endpoints::EndpointScope::Local => "local",
6602 crate::endpoints::EndpointScope::Lan => "lan",
6603 crate::endpoints::EndpointScope::Uds => "uds",
6604 crate::endpoints::EndpointScope::Federation => "federation",
6605 },
6606 "status": "drop_sent",
6607 }))?
6608 );
6609 } else {
6610 let scope = match delivery_endpoint.scope {
6611 crate::endpoints::EndpointScope::Local => "local",
6612 crate::endpoints::EndpointScope::Lan => "lan",
6613 crate::endpoints::EndpointScope::Uds => "uds",
6614 crate::endpoints::EndpointScope::Federation => "federation",
6615 };
6616 println!(
6617 "→ 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.",
6618 delivery_endpoint.relay_url
6619 );
6620 }
6621 Ok(())
6622}
6623
6624fn cmd_add(
6625 handle_arg: &str,
6626 relay_override: Option<&str>,
6627 local_sister: bool,
6628 as_json: bool,
6629) -> Result<()> {
6630 if local_sister {
6638 let resolved = crate::session::resolve_local_sister(handle_arg)
6639 .unwrap_or_else(|| handle_arg.to_string());
6640 return cmd_add_local_sister(&resolved, as_json);
6641 }
6642 if !handle_arg.contains('@')
6643 && let Some(resolved) = crate::session::resolve_local_sister(handle_arg)
6644 {
6645 eprintln!(
6646 "wire add: `{handle_arg}` resolved to local sister session `{resolved}` \
6647 — routing via --local-sister (disk-read card, no relay lookup)."
6648 );
6649 return cmd_add_local_sister(&resolved, as_json);
6650 }
6651 if !handle_arg.contains('@') {
6652 bail!(
6653 "`{handle_arg}` doesn't match any local sister session and has no \
6654 @<relay> suffix for federation.\n\
6655 — Local sisters: `wire session list-local` (operator types name OR \
6656 character nickname)\n\
6657 — Federation: `wire add <handle>@<relay-domain>` (e.g. \
6658 `wire add alice@wireup.net`)"
6659 );
6660 }
6661 let parsed = crate::pair_profile::parse_handle(handle_arg)?;
6662
6663 let (our_did, our_relay, our_slot_id, our_slot_token) =
6665 crate::pair_invite::ensure_self_with_relay(relay_override)?;
6666 if our_did == format!("did:wire:{}", parsed.nick) {
6667 bail!("refusing to add self (handle matches own DID)");
6669 }
6670
6671 if let Some(pending) = crate::pending_inbound_pair::read_pending_inbound(&parsed.nick)? {
6681 return cmd_add_accept_pending(
6682 handle_arg,
6683 &parsed.nick,
6684 &pending,
6685 &our_relay,
6686 &our_slot_id,
6687 &our_slot_token,
6688 as_json,
6689 );
6690 }
6691
6692 if !is_known_relay_domain(&parsed.domain, &our_relay) {
6709 eprintln!(
6710 "wire add: WARN unfamiliar relay domain `{}`.",
6711 parsed.domain
6712 );
6713 eprintln!(
6714 " This is NOT `wireup.net` (the default), NOT your own relay (`{}`), ",
6715 host_of_url(&our_relay)
6716 );
6717 eprintln!(
6718 " and not on the known-good list. If you meant `{}@wireup.net`, ",
6719 parsed.nick
6720 );
6721 eprintln!(
6722 " run `wire add {}@wireup.net` instead. Otherwise verify with your",
6723 parsed.nick
6724 );
6725 eprintln!(" peer out-of-band that they actually run a relay at this domain");
6726 eprintln!(" before relying on the pair. (See issue #9.4.)");
6727 }
6728
6729 let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)?;
6731 let peer_card = resolved
6732 .get("card")
6733 .cloned()
6734 .ok_or_else(|| anyhow!("resolved missing card"))?;
6735 let peer_did = resolved
6736 .get("did")
6737 .and_then(Value::as_str)
6738 .ok_or_else(|| anyhow!("resolved missing did"))?
6739 .to_string();
6740 let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
6741 let peer_slot_id = resolved
6742 .get("slot_id")
6743 .and_then(Value::as_str)
6744 .ok_or_else(|| anyhow!("resolved missing slot_id"))?
6745 .to_string();
6746 let peer_relay = resolved
6747 .get("relay_url")
6748 .and_then(Value::as_str)
6749 .map(str::to_string)
6750 .or_else(|| relay_override.map(str::to_string))
6751 .unwrap_or_else(|| format!("https://{}", parsed.domain));
6752
6753 let mut trust = config::read_trust()?;
6755 crate::trust::add_agent_card_pin(&mut trust, &peer_card, Some("VERIFIED"));
6756 config::write_trust(&trust)?;
6757 let mut relay_state = config::read_relay_state()?;
6758 let mut endpoints: Vec<crate::endpoints::Endpoint> = relay_state
6771 .get("peers")
6772 .and_then(|p| p.get(&peer_handle))
6773 .and_then(|e| e.get("endpoints"))
6774 .and_then(|a| serde_json::from_value::<Vec<crate::endpoints::Endpoint>>(a.clone()).ok())
6775 .unwrap_or_default();
6776 let fed_token = endpoints
6777 .iter()
6778 .find(|e| {
6779 e.relay_url == peer_relay && e.scope == crate::endpoints::EndpointScope::Federation
6780 })
6781 .map(|e| e.slot_token.clone())
6782 .unwrap_or_default();
6783 let fed_ep = crate::endpoints::Endpoint {
6784 relay_url: peer_relay.clone(),
6785 slot_id: peer_slot_id.clone(),
6786 slot_token: fed_token, scope: crate::endpoints::EndpointScope::Federation,
6788 };
6789 if let Some(existing) = endpoints
6790 .iter_mut()
6791 .find(|e| e.relay_url == fed_ep.relay_url)
6792 {
6793 *existing = fed_ep;
6794 } else {
6795 endpoints.push(fed_ep);
6796 }
6797 crate::endpoints::pin_peer_endpoints(&mut relay_state, &peer_handle, &endpoints)?;
6798 config::write_relay_state(&relay_state)?;
6799
6800 let our_card = config::read_agent_card()?;
6803 let sk_seed = config::read_private_key()?;
6804 let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
6805 let pk_b64 = our_card
6806 .get("verify_keys")
6807 .and_then(Value::as_object)
6808 .and_then(|m| m.values().next())
6809 .and_then(|v| v.get("key"))
6810 .and_then(Value::as_str)
6811 .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
6812 let pk_bytes = crate::signing::b64decode(pk_b64)?;
6813 let now = time::OffsetDateTime::now_utc()
6814 .format(&time::format_description::well_known::Rfc3339)
6815 .unwrap_or_default();
6816 let our_relay_state = config::read_relay_state().unwrap_or_else(|_| json!({}));
6821 let our_endpoints = crate::endpoints::self_endpoints(&our_relay_state);
6822 let mut body = json!({
6823 "card": our_card,
6824 "relay_url": our_relay,
6825 "slot_id": our_slot_id,
6826 "slot_token": our_slot_token,
6827 });
6828 if !our_endpoints.is_empty() {
6829 body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
6830 }
6831 let event = json!({
6832 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
6833 "timestamp": now,
6834 "from": our_did,
6835 "to": peer_did,
6836 "type": "pair_drop",
6837 "kind": 1100u32,
6838 "body": body,
6839 });
6840 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
6841
6842 let client = crate::relay_client::RelayClient::new(&peer_relay);
6844 let resp = client.handle_intro(&parsed.nick, &signed)?;
6845 let event_id = signed
6846 .get("event_id")
6847 .and_then(Value::as_str)
6848 .unwrap_or("")
6849 .to_string();
6850
6851 if as_json {
6852 println!(
6853 "{}",
6854 serde_json::to_string(&json!({
6855 "handle": handle_arg,
6856 "paired_with": peer_did,
6857 "peer_handle": peer_handle,
6858 "event_id": event_id,
6859 "drop_response": resp,
6860 "status": "drop_sent",
6861 }))?
6862 );
6863 } else {
6864 println!(
6865 "→ 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."
6866 );
6867 }
6868 Ok(())
6869}
6870
6871fn cmd_add_accept_pending(
6878 handle_arg: &str,
6879 peer_nick: &str,
6880 pending: &crate::pending_inbound_pair::PendingInboundPair,
6881 _our_relay: &str,
6882 _our_slot_id: &str,
6883 _our_slot_token: &str,
6884 as_json: bool,
6885) -> Result<()> {
6886 let mut trust = config::read_trust()?;
6889 crate::trust::add_agent_card_pin(&mut trust, &pending.peer_card, Some("VERIFIED"));
6890 config::write_trust(&trust)?;
6891
6892 let mut relay_state = config::read_relay_state()?;
6898 let endpoints_to_pin = if pending.peer_endpoints.is_empty() {
6899 vec![crate::endpoints::Endpoint::federation(
6900 pending.peer_relay_url.clone(),
6901 pending.peer_slot_id.clone(),
6902 pending.peer_slot_token.clone(),
6903 )]
6904 } else {
6905 pending.peer_endpoints.clone()
6906 };
6907 crate::endpoints::pin_peer_endpoints(
6908 &mut relay_state,
6909 &pending.peer_handle,
6910 &endpoints_to_pin,
6911 )?;
6912 config::write_relay_state(&relay_state)?;
6913
6914 crate::pair_invite::send_pair_drop_ack(
6916 &pending.peer_handle,
6917 &pending.peer_relay_url,
6918 &pending.peer_slot_id,
6919 &pending.peer_slot_token,
6920 )
6921 .with_context(|| {
6922 format!(
6923 "pair_drop_ack send to {} @ {} slot {} failed",
6924 pending.peer_handle, pending.peer_relay_url, pending.peer_slot_id
6925 )
6926 })?;
6927
6928 crate::pending_inbound_pair::consume_pending_inbound(peer_nick)?;
6930
6931 if as_json {
6932 println!(
6933 "{}",
6934 serde_json::to_string(&json!({
6935 "handle": handle_arg,
6936 "paired_with": pending.peer_did,
6937 "peer_handle": pending.peer_handle,
6938 "status": "bilateral_accepted",
6939 "via": "pending_inbound",
6940 }))?
6941 );
6942 } else {
6943 println!(
6944 "→ 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} \"...\"`.",
6945 peer = pending.peer_handle,
6946 );
6947 }
6948 Ok(())
6949}
6950
6951fn cmd_pair_accept(peer_nick: &str, as_json: bool) -> Result<()> {
6958 let nick = crate::agent_card::bare_handle(peer_nick);
6959 let pending = crate::pending_inbound_pair::read_pending_inbound(nick)?.ok_or_else(|| {
6960 anyhow!(
6961 "no pending pair request from {nick}. Run `wire pair-list-inbound` to see who is waiting, \
6962 or use `wire add <peer>@<relay>` to send a fresh outbound pair request."
6963 )
6964 })?;
6965 let (_our_did, our_relay, our_slot_id, our_slot_token) =
6966 crate::pair_invite::ensure_self_with_relay(None)?;
6967 let handle_arg = format!("{}@{}", pending.peer_handle, pending.peer_relay_url);
6968 cmd_add_accept_pending(
6969 &handle_arg,
6970 nick,
6971 &pending,
6972 &our_relay,
6973 &our_slot_id,
6974 &our_slot_token,
6975 as_json,
6976 )
6977}
6978
6979fn cmd_pair_list_inbound(as_json: bool) -> Result<()> {
6982 let items = crate::pending_inbound_pair::list_pending_inbound()?;
6983 if as_json {
6984 println!("{}", serde_json::to_string(&items)?);
6985 return Ok(());
6986 }
6987 if items.is_empty() {
6988 println!("no pending pair requests — your inbox is clear.");
6989 return Ok(());
6990 }
6991 let plural = if items.len() == 1 { "" } else { "s" };
6998 println!("{} pending pair request{plural}:\n", items.len());
6999 for p in &items {
7000 let ch = crate::character::Character::from_did(&p.peer_did);
7001 let glyph = crate::character::emoji_with_fallback(&ch);
7002 println!(
7005 " {glyph} {nick} ({handle}) wants to pair with you",
7006 nick = ch.nickname,
7007 handle = p.peer_handle,
7008 );
7009 }
7010 println!();
7011 println!(
7012 "→ to accept any: `wire accept <name>` (e.g. `wire accept {first}`)",
7013 first = items
7014 .first()
7015 .map(|p| {
7016 let ch = crate::character::Character::from_did(&p.peer_did);
7017 ch.nickname
7018 })
7019 .unwrap_or_else(|| "<name>".to_string())
7020 );
7021 println!("→ to refuse: `wire reject <name>`");
7022 Ok(())
7023}
7024
7025fn cmd_pair_reject(peer_nick: &str, as_json: bool) -> Result<()> {
7029 let nick = crate::agent_card::bare_handle(peer_nick);
7030 let existed = crate::pending_inbound_pair::read_pending_inbound(nick)?;
7031 crate::pending_inbound_pair::consume_pending_inbound(nick)?;
7032
7033 if as_json {
7034 println!(
7035 "{}",
7036 serde_json::to_string(&json!({
7037 "peer": nick,
7038 "rejected": existed.is_some(),
7039 "had_pending": existed.is_some(),
7040 }))?
7041 );
7042 } else if existed.is_some() {
7043 println!(
7044 "→ rejected pending pair from {nick}\n→ pending-inbound record deleted; no ack sent."
7045 );
7046 } else {
7047 println!("no pending pair from {nick} — nothing to reject");
7048 }
7049 Ok(())
7050}
7051
7052fn cmd_group(cmd: GroupCommand) -> Result<()> {
7063 match cmd {
7064 GroupCommand::Create { name, json } => cmd_group_create(&name, json),
7065 GroupCommand::Add { group, peer, json } => cmd_group_add(&group, &peer, json),
7066 GroupCommand::Send {
7067 group,
7068 message,
7069 json,
7070 } => cmd_group_send(&group, &message, json),
7071 GroupCommand::Tail { group, limit, json } => cmd_group_tail(&group, limit, json),
7072 GroupCommand::List { json } => cmd_group_list(json),
7073 GroupCommand::Invite { group, json } => cmd_group_invite(&group, json),
7074 GroupCommand::Join { code, json } => cmd_group_join(&code, json),
7075 }
7076}
7077
7078fn group_self() -> Result<(String, String, String, String)> {
7081 let card = config::read_agent_card()?;
7082 let did = card
7083 .get("did")
7084 .and_then(Value::as_str)
7085 .ok_or_else(|| anyhow!("agent-card missing did — run `wire up` first"))?
7086 .to_string();
7087 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
7088 let pk_b64 = card
7089 .get("verify_keys")
7090 .and_then(Value::as_object)
7091 .and_then(|m| m.values().next())
7092 .and_then(|v| v.get("key"))
7093 .and_then(Value::as_str)
7094 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?
7095 .to_string();
7096 let pk_bytes = crate::signing::b64decode(&pk_b64)?;
7097 let key_id = make_key_id(&handle, &pk_bytes);
7098 Ok((did, handle, key_id, pk_b64))
7099}
7100
7101fn group_room_relay_url() -> Result<String> {
7104 use crate::endpoints::EndpointScope;
7105 let state = config::read_relay_state()?;
7106 let eps = crate::endpoints::self_endpoints(&state);
7107 let pick = eps
7108 .iter()
7109 .find(|e| e.scope == EndpointScope::Federation)
7110 .or_else(|| eps.iter().find(|e| e.scope == EndpointScope::Lan))
7111 .or_else(|| eps.iter().find(|e| e.scope == EndpointScope::Local))
7112 .or_else(|| eps.first());
7113 match pick {
7114 Some(e) if !e.relay_url.is_empty() => Ok(e.relay_url.clone()),
7115 _ => bail!("no relay endpoint on this identity — run `wire up --relay <url>` first"),
7116 }
7117}
7118
7119fn distribute_group_invite(group: &crate::group::Group, self_did: &str) -> Result<usize> {
7123 let (_, self_handle, _, pk_b64) = group_self()?;
7124 let sk_seed = config::read_private_key()?;
7125 let pk_bytes = crate::signing::b64decode(&pk_b64)?;
7126 let now_iso = time::OffsetDateTime::now_utc()
7127 .format(&time::format_description::well_known::Rfc3339)
7128 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
7129 let group_json = serde_json::to_value(group)?;
7130 let mut delivered = 0usize;
7131 for handle in group.other_member_handles(self_did) {
7132 let event = json!({
7133 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
7134 "timestamp": now_iso,
7135 "from": self_did,
7136 "to": format!("did:wire:{handle}"),
7137 "type": "group_invite",
7138 "kind": parse_kind("group_invite")?,
7139 "body": group_json,
7140 });
7141 let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &self_handle)
7142 .map_err(|e| anyhow!("signing group_invite for `{handle}`: {e:?}"))?;
7143 let line = serde_json::to_vec(&signed)?;
7144 if config::append_outbox_record(&handle, &line).is_ok() {
7145 delivered += 1;
7146 }
7147 }
7148 Ok(delivered)
7149}
7150
7151fn introduce_pin(
7158 trust: &mut Value,
7159 handle: &str,
7160 did: &str,
7161 key_id: &str,
7162 key: &str,
7163 group_id: &str,
7164) -> bool {
7165 let now = time::OffsetDateTime::now_utc()
7166 .format(&time::format_description::well_known::Rfc3339)
7167 .unwrap_or_default();
7168 let agents = trust
7169 .as_object_mut()
7170 .expect("trust is an object")
7171 .entry("agents")
7172 .or_insert_with(|| json!({}));
7173 let key_rec = json!({"key_id": key_id, "key": key, "added_at": now, "active": true});
7174 match agents.get_mut(handle) {
7175 Some(existing) => {
7176 let keys = existing
7179 .as_object_mut()
7180 .and_then(|o| o.get_mut("public_keys"))
7181 .and_then(Value::as_array_mut);
7182 if let Some(keys) = keys {
7183 let have = keys
7184 .iter()
7185 .any(|k| k.get("key_id").and_then(Value::as_str) == Some(key_id));
7186 if !have {
7187 keys.push(key_rec);
7188 return true;
7189 }
7190 }
7191 false
7192 }
7193 None => {
7194 agents[handle] = json!({
7196 "tier": "UNTRUSTED",
7197 "did": did,
7198 "public_keys": [key_rec],
7199 "introduced_via": group_id,
7200 "pinned_at": now,
7201 });
7202 true
7203 }
7204 }
7205}
7206
7207fn ingest_group_invites() -> Result<()> {
7213 let inbox = config::inbox_dir()?;
7214 if !inbox.exists() {
7215 return Ok(());
7216 }
7217 let (self_did, ..) = group_self()?;
7218 let trust_now = config::read_trust().unwrap_or_else(|_| json!({"agents": {}}));
7219 let mut best: std::collections::HashMap<String, crate::group::Group> =
7221 std::collections::HashMap::new();
7222
7223 for entry in std::fs::read_dir(&inbox)?.flatten() {
7224 let path = entry.path();
7225 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
7226 continue;
7227 }
7228 for line in std::fs::read_to_string(&path).unwrap_or_default().lines() {
7229 let event: Value = match serde_json::from_str(line) {
7230 Ok(v) => v,
7231 Err(_) => continue,
7232 };
7233 if event.get("type").and_then(Value::as_str) != Some("group_invite") {
7234 continue;
7235 }
7236 if verify_message_v31(&event, &trust_now).is_err() {
7239 continue;
7240 }
7241 let Some(body) = event.get("body") else {
7242 continue;
7243 };
7244 let group: crate::group::Group = match serde_json::from_value(body.clone()) {
7245 Ok(g) => g,
7246 Err(_) => continue,
7247 };
7248 if group.creator_did == self_did {
7249 continue; }
7251 let from_did = event.get("from").and_then(Value::as_str).unwrap_or("");
7253 if from_did != group.creator_did {
7254 continue;
7255 }
7256 let creator_handle = crate::agent_card::display_handle_from_did(&group.creator_did);
7259 let creator_key = trust_now
7260 .get("agents")
7261 .and_then(|a| a.get(creator_handle))
7262 .and_then(|a| a.get("public_keys"))
7263 .and_then(Value::as_array)
7264 .and_then(|ks| ks.first())
7265 .and_then(|k| k.get("key"))
7266 .and_then(Value::as_str)
7267 .and_then(|b| crate::signing::b64decode(b).ok());
7268 let Some(creator_key) = creator_key else {
7269 continue;
7270 };
7271 if !group.verify(&creator_key) {
7272 continue;
7273 }
7274 match best.get(&group.id) {
7275 Some(prev) if prev.epoch >= group.epoch => {}
7276 _ => {
7277 best.insert(group.id.clone(), group);
7278 }
7279 }
7280 }
7281 }
7282
7283 if best.is_empty() {
7284 return Ok(());
7285 }
7286 let mut trust = config::read_trust()?;
7287 for group in best.values() {
7288 if let Ok(local) = crate::group::load_group(&group.id)
7290 && local.epoch >= group.epoch
7291 {
7292 continue;
7293 }
7294 crate::group::save_group(group)?;
7295 for m in &group.members {
7296 if m.did == self_did || m.key.is_empty() {
7297 continue;
7298 }
7299 introduce_pin(&mut trust, &m.handle, &m.did, &m.key_id, &m.key, &group.id);
7300 }
7301 }
7302 config::write_trust(&trust)?;
7303 Ok(())
7304}
7305
7306fn cmd_group_create(name: &str, as_json: bool) -> Result<()> {
7307 if !config::is_initialized()? {
7308 bail!("not initialized — run `wire up` first");
7309 }
7310 let (did, handle, key_id, pk_b64) = group_self()?;
7311 let relay_url = group_room_relay_url()?;
7312 let client = crate::relay_client::RelayClient::new(&relay_url);
7314 let room = client
7315 .allocate_slot(Some(&format!("group:{name}")))
7316 .with_context(|| format!("allocating group room on {relay_url}"))?;
7317 let id = format!("g{:016x}", rand::random::<u64>());
7318 let mut group = crate::group::Group::new(id.clone(), name.to_string(), handle, did.clone());
7319 group.set_room(relay_url, room.slot_id, room.slot_token);
7320 group.set_member_keys(&did, key_id, pk_b64)?;
7321 let sk = config::read_private_key()?;
7322 group.sign(&sk)?;
7323 crate::group::save_group(&group)?;
7324 if as_json {
7325 println!(
7326 "{}",
7327 serde_json::to_string(&json!({
7328 "id": id, "name": name, "members": 1, "relay_url": group.relay_url
7329 }))?
7330 );
7331 } else {
7332 println!(
7333 "created group `{name}` (id {id}) — room on {}. You are the creator.",
7334 group.relay_url
7335 );
7336 println!(" add peers: `wire group add {id} <peer>` talk: `wire group send {id} \"hi\"`");
7337 }
7338 Ok(())
7339}
7340
7341fn cmd_group_add(group_ref: &str, peer: &str, as_json: bool) -> Result<()> {
7342 let (self_did, ..) = group_self()?;
7343 let mut group = crate::group::resolve_group(group_ref)?;
7344 if group.creator_did != self_did {
7345 bail!("only the group creator can add members (the creator signs the roster)");
7346 }
7347 let bare = crate::agent_card::bare_handle(peer).to_string();
7349 let trust = config::read_trust()?;
7350 let agent = trust
7351 .get("agents")
7352 .and_then(|a| a.get(&bare))
7353 .ok_or_else(|| {
7354 anyhow!("`{bare}` is not a pinned peer — pair first (`wire dial {bare}@<relay>`)")
7355 })?;
7356 let tier = agent
7357 .get("tier")
7358 .and_then(Value::as_str)
7359 .unwrap_or("UNTRUSTED");
7360 if tier != "VERIFIED" {
7361 bail!(
7362 "`{bare}` is {tier}, not VERIFIED — only verified peers can be added as Members (T22 consent)"
7363 );
7364 }
7365 let peer_did = agent
7366 .get("did")
7367 .and_then(Value::as_str)
7368 .ok_or_else(|| anyhow!("trust entry for `{bare}` is missing a did"))?
7369 .to_string();
7370 let key = agent
7373 .get("public_keys")
7374 .and_then(Value::as_array)
7375 .and_then(|ks| {
7376 ks.iter()
7377 .find(|k| k.get("active").and_then(Value::as_bool).unwrap_or(true))
7378 })
7379 .ok_or_else(|| anyhow!("no active pinned key for `{bare}` in trust"))?;
7380 let peer_key_id = key
7381 .get("key_id")
7382 .and_then(Value::as_str)
7383 .unwrap_or_default()
7384 .to_string();
7385 let peer_pk = key
7386 .get("key")
7387 .and_then(Value::as_str)
7388 .unwrap_or_default()
7389 .to_string();
7390
7391 group.add_member(
7392 bare.clone(),
7393 peer_did.clone(),
7394 crate::group::GroupTier::Member,
7395 )?;
7396 group.set_member_keys(&peer_did, peer_key_id, peer_pk)?;
7397 let sk = config::read_private_key()?;
7398 group.sign(&sk)?;
7399 crate::group::save_group(&group)?;
7400 let delivered = distribute_group_invite(&group, &self_did).unwrap_or(0);
7403 if as_json {
7404 println!(
7405 "{}",
7406 serde_json::to_string(&json!({
7407 "group": group.id, "added": bare, "epoch": group.epoch,
7408 "members": group.members.len(), "invites_queued": delivered
7409 }))?
7410 );
7411 } else {
7412 println!(
7413 "added `{bare}` to `{}` — now {} member(s), epoch {} ({delivered} invite(s) queued; run `wire push`)",
7414 group.name,
7415 group.members.len(),
7416 group.epoch
7417 );
7418 }
7419 Ok(())
7420}
7421
7422fn cmd_group_send(group_ref: &str, message: &str, as_json: bool) -> Result<()> {
7423 if !config::is_initialized()? {
7424 bail!("not initialized — run `wire up` first");
7425 }
7426 ingest_group_invites()?;
7427 let (self_did, self_handle, _, pk_b64) = group_self()?;
7428 let group = crate::group::resolve_group(group_ref)?;
7429 if group.slot_id.is_empty() || group.relay_url.is_empty() {
7434 bail!(
7435 "group `{}` has no room slot (legacy/partial group)",
7436 group.name
7437 );
7438 }
7439 let sk_seed = config::read_private_key()?;
7440 let pk_bytes = crate::signing::b64decode(&pk_b64)?;
7441 let now_iso = time::OffsetDateTime::now_utc()
7442 .format(&time::format_description::well_known::Rfc3339)
7443 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
7444 let event = json!({
7445 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
7446 "timestamp": now_iso,
7447 "from": self_did,
7448 "to": format!("did:wire:group:{}", group.id),
7449 "type": "group_msg",
7450 "kind": parse_kind("group_msg")?,
7451 "body": {
7452 "group_id": group.id,
7453 "group_name": group.name,
7454 "epoch": group.epoch,
7455 "text": message,
7456 },
7457 });
7458 let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &self_handle)
7459 .map_err(|e| anyhow!("signing group_msg: {e:?}"))?;
7460 let client = crate::relay_client::RelayClient::new(&group.relay_url);
7462 client
7463 .post_event(&group.slot_id, &group.slot_token, &signed)
7464 .with_context(|| {
7465 format!(
7466 "posting to group room {} on {}",
7467 group.slot_id, group.relay_url
7468 )
7469 })?;
7470 if as_json {
7471 println!(
7472 "{}",
7473 serde_json::to_string(&json!({
7474 "group": group.id, "epoch": group.epoch, "status": "posted",
7475 "members": group.members.len()
7476 }))?
7477 );
7478 } else {
7479 println!(
7480 "group `{}`: posted to the room ({} member(s))",
7481 group.name,
7482 group.members.len()
7483 );
7484 }
7485 Ok(())
7486}
7487
7488fn cmd_group_tail(group_ref: &str, limit: usize, as_json: bool) -> Result<()> {
7489 ingest_group_invites()?;
7490 let group = crate::group::resolve_group(group_ref)?;
7491 if group.slot_id.is_empty() || group.relay_url.is_empty() {
7492 bail!(
7493 "group `{}` has no room slot (legacy/partial group)",
7494 group.name
7495 );
7496 }
7497 let mut trust = config::read_trust().unwrap_or_else(|_| json!({"agents": {}}));
7498 let client = crate::relay_client::RelayClient::new(&group.relay_url);
7499 let fetch = if limit == 0 {
7501 1000
7502 } else {
7503 (limit * 4).min(1000)
7504 };
7505 let events = client
7506 .list_events(&group.slot_id, &group.slot_token, None, Some(fetch))
7507 .with_context(|| {
7508 format!(
7509 "pulling group room {} on {}",
7510 group.slot_id, group.relay_url
7511 )
7512 })?;
7513
7514 let mut trust_changed = false;
7520 for event in &events {
7521 if event.get("type").and_then(Value::as_str) != Some("group_join") {
7522 continue;
7523 }
7524 if let Some((h, did, kid, key)) = group_join_pin_material(event)
7525 && introduce_pin(&mut trust, &h, &did, &kid, &key, &group.id)
7526 {
7527 trust_changed = true;
7528 }
7529 }
7530 if trust_changed {
7531 let _ = config::write_trust(&trust);
7532 }
7533
7534 enum Line {
7537 Msg {
7538 from: String,
7539 text: String,
7540 verified: bool,
7541 },
7542 Join {
7543 who: String,
7544 },
7545 }
7546 let mut timeline: Vec<(String, Line)> = Vec::new();
7547 for event in &events {
7548 let ty = event.get("type").and_then(Value::as_str).unwrap_or("");
7549 let body = match event.get("body") {
7550 Some(Value::String(s)) => serde_json::from_str::<Value>(s).ok(),
7551 Some(v) => Some(v.clone()),
7552 None => None,
7553 };
7554 let Some(body) = body else { continue };
7555 if body.get("group_id").and_then(Value::as_str) != Some(group.id.as_str()) {
7556 continue;
7557 }
7558 let ts = event
7559 .get("timestamp")
7560 .and_then(Value::as_str)
7561 .unwrap_or("")
7562 .to_string();
7563 let from_did = event.get("from").and_then(Value::as_str).unwrap_or("");
7564 let from_handle = crate::agent_card::display_handle_from_did(from_did).to_string();
7565 match ty {
7566 "group_msg" => {
7567 let text = body
7568 .get("text")
7569 .and_then(Value::as_str)
7570 .unwrap_or("")
7571 .to_string();
7572 let verified = verify_message_v31(event, &trust).is_ok();
7573 timeline.push((
7574 ts,
7575 Line::Msg {
7576 from: from_handle,
7577 text,
7578 verified,
7579 },
7580 ));
7581 }
7582 "group_join" => timeline.push((ts, Line::Join { who: from_handle })),
7583 _ => {}
7584 }
7585 }
7586 timeline.sort_by(|a, b| a.0.cmp(&b.0));
7587 let start = if limit > 0 {
7588 timeline.len().saturating_sub(limit)
7589 } else {
7590 0
7591 };
7592 let recent = &timeline[start..];
7593 if as_json {
7594 let arr: Vec<Value> = recent
7595 .iter()
7596 .map(|(ts, l)| match l {
7597 Line::Msg {
7598 from,
7599 text,
7600 verified,
7601 } => {
7602 json!({"ts": ts, "type": "msg", "from": from, "text": text, "verified": verified})
7603 }
7604 Line::Join { who } => json!({"ts": ts, "type": "join", "from": who}),
7605 })
7606 .collect();
7607 println!(
7608 "{}",
7609 serde_json::to_string(
7610 &json!({"group": group.id, "name": group.name, "messages": arr})
7611 )?
7612 );
7613 } else if recent.is_empty() {
7614 println!("group `{}`: no messages yet", group.name);
7615 } else {
7616 for (ts, l) in recent {
7617 let short_ts: String = ts.chars().take(19).collect();
7618 match l {
7619 Line::Msg {
7620 from,
7621 text,
7622 verified,
7623 } => {
7624 let mark = if *verified { "✓" } else { "✗" };
7625 println!("[{short_ts}] {} {mark}: {text}", persona_label(from));
7626 }
7627 Line::Join { who } => println!("[{short_ts}] {} joined", persona_label(who)),
7628 }
7629 }
7630 }
7631 Ok(())
7632}
7633
7634fn group_join_pin_material(event: &Value) -> Option<(String, String, String, String)> {
7640 let body = match event.get("body") {
7641 Some(Value::String(s)) => serde_json::from_str::<Value>(s).ok()?,
7642 Some(v) => v.clone(),
7643 None => return None,
7644 };
7645 let card = body.get("joiner_card")?;
7646 let mut tmp = json!({"agents": {}});
7648 crate::trust::add_agent_card_pin(&mut tmp, card, Some("UNTRUSTED"));
7649 if verify_message_v31(event, &tmp).is_err() {
7650 return None;
7651 }
7652 let did = card.get("did").and_then(Value::as_str)?.to_string();
7653 let handle = card
7654 .get("handle")
7655 .and_then(Value::as_str)
7656 .map(str::to_string)
7657 .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
7658 let (kid_full, krec) = card
7659 .get("verify_keys")
7660 .and_then(Value::as_object)
7661 .and_then(|m| m.iter().next())?;
7662 let key_id = kid_full
7663 .strip_prefix("ed25519:")
7664 .unwrap_or(kid_full)
7665 .to_string();
7666 let key = krec.get("key").and_then(Value::as_str)?.to_string();
7667 Some((handle, did, key_id, key))
7668}
7669
7670fn cmd_group_invite(group_ref: &str, as_json: bool) -> Result<()> {
7673 let group = crate::group::resolve_group(group_ref)?;
7674 if group.slot_id.is_empty() || group.relay_url.is_empty() {
7675 bail!(
7676 "group `{}` has no room slot — nothing to invite into",
7677 group.name
7678 );
7679 }
7680 if group.creator_sig.is_empty() {
7681 bail!(
7682 "group `{}` roster is unsigned — add a member or recreate before inviting",
7683 group.name
7684 );
7685 }
7686 let payload = serde_json::to_vec(&group)?;
7687 let code = format!("wire-group:{}", crate::signing::b64encode(&payload));
7688 if as_json {
7689 println!(
7690 "{}",
7691 serde_json::to_string(&json!({"group": group.id, "name": group.name, "code": code}))?
7692 );
7693 } else {
7694 println!(
7695 "join code for `{}` — share ONLY with people you want in the room (it IS the room key):\n",
7696 group.name
7697 );
7698 println!("{code}\n");
7699 println!("they run: wire group join <code>");
7700 }
7701 Ok(())
7702}
7703
7704fn cmd_group_join(code: &str, as_json: bool) -> Result<()> {
7708 if !config::is_initialized()? {
7709 bail!("not initialized — run `wire up` first");
7710 }
7711 let raw = code.trim();
7712 let b64 = raw.strip_prefix("wire-group:").unwrap_or(raw);
7713 let payload =
7714 crate::signing::b64decode(b64).map_err(|_| anyhow!("invalid join code (not base64)"))?;
7715 let group: crate::group::Group = serde_json::from_slice(&payload)
7716 .map_err(|_| anyhow!("invalid join code (not a group payload)"))?;
7717 if group.slot_id.is_empty() || group.relay_url.is_empty() {
7718 bail!("join code carries no room coords");
7719 }
7720 let creator_key = group
7723 .members
7724 .iter()
7725 .find(|m| m.did == group.creator_did)
7726 .map(|m| m.key.clone())
7727 .filter(|k| !k.is_empty())
7728 .and_then(|k| crate::signing::b64decode(&k).ok())
7729 .ok_or_else(|| anyhow!("join code is missing the creator's key"))?;
7730 if !group.verify(&creator_key) {
7731 bail!("join code failed its signature check (tampered or corrupt)");
7732 }
7733 let (self_did, self_handle, _, _) = group_self()?;
7734 if group.creator_did == self_did {
7735 bail!("you created group `{}` — you're already in it", group.name);
7736 }
7737
7738 crate::group::save_group(&group)?;
7740 let mut trust = config::read_trust()?;
7741 for m in &group.members {
7742 if m.did == self_did || m.key.is_empty() {
7743 continue;
7744 }
7745 introduce_pin(&mut trust, &m.handle, &m.did, &m.key_id, &m.key, &group.id);
7746 }
7747 config::write_trust(&trust)?;
7748
7749 let card = config::read_agent_card()?;
7751 let sk_seed = config::read_private_key()?;
7752 let pk_b64 = card
7753 .get("verify_keys")
7754 .and_then(Value::as_object)
7755 .and_then(|m| m.values().next())
7756 .and_then(|v| v.get("key"))
7757 .and_then(Value::as_str)
7758 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
7759 let pk_bytes = crate::signing::b64decode(pk_b64)?;
7760 let now_iso = time::OffsetDateTime::now_utc()
7761 .format(&time::format_description::well_known::Rfc3339)
7762 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
7763 let event = json!({
7764 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
7765 "timestamp": now_iso,
7766 "from": self_did,
7767 "to": format!("did:wire:group:{}", group.id),
7768 "type": "group_join",
7769 "kind": parse_kind("group_join")?,
7770 "body": {
7771 "group_id": group.id,
7772 "group_name": group.name,
7773 "epoch": group.epoch,
7774 "joiner_card": card,
7775 "text": "joined",
7776 },
7777 });
7778 let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &self_handle)
7779 .map_err(|e| anyhow!("signing group_join: {e:?}"))?;
7780 let client = crate::relay_client::RelayClient::new(&group.relay_url);
7781 let announced = client
7782 .post_event(&group.slot_id, &group.slot_token, &signed)
7783 .is_ok();
7784
7785 if as_json {
7786 println!(
7787 "{}",
7788 serde_json::to_string(&json!({
7789 "group": group.id, "name": group.name, "joined": true,
7790 "members": group.members.len(), "announced": announced
7791 }))?
7792 );
7793 } else {
7794 println!(
7795 "joined group `{}` ({} member(s)) at Introduced tier.",
7796 group.name,
7797 group.members.len()
7798 );
7799 if announced {
7800 println!(" announced to the room — members will verify your messages.");
7801 } else {
7802 println!(
7803 " ⚠ couldn't reach the room relay to announce; retry a `wire group send` so members can verify you."
7804 );
7805 }
7806 println!(
7807 " read: `wire group tail {}` talk: `wire group send {} \"hi\"`",
7808 group.id, group.id
7809 );
7810 }
7811 Ok(())
7812}
7813
7814fn cmd_group_list(as_json: bool) -> Result<()> {
7815 let groups = crate::group::list_groups()?;
7816 if as_json {
7817 let arr: Vec<Value> = groups
7818 .iter()
7819 .map(|g| {
7820 json!({
7821 "id": g.id,
7822 "name": g.name,
7823 "epoch": g.epoch,
7824 "members": g.members.iter().map(|m| json!({"handle": m.handle, "tier": m.tier.as_str()})).collect::<Vec<_>>(),
7825 })
7826 })
7827 .collect();
7828 println!("{}", serde_json::to_string(&json!({"groups": arr}))?);
7829 } else if groups.is_empty() {
7830 println!("no groups yet — create one with `wire group create <name>`");
7831 } else {
7832 for g in &groups {
7833 println!(
7834 "{} ({}) — {} member(s), epoch {}",
7835 g.name,
7836 g.id,
7837 g.members.len(),
7838 g.epoch
7839 );
7840 for m in &g.members {
7841 println!(" {} [{}]", m.handle, m.tier.as_str());
7842 }
7843 }
7844 }
7845 Ok(())
7846}
7847
7848fn cmd_mesh(cmd: MeshCommand) -> Result<()> {
7851 match cmd {
7852 MeshCommand::Status { stale_secs, json } => cmd_session_mesh_status(stale_secs, json),
7853 MeshCommand::Broadcast {
7854 kind,
7855 scope,
7856 exclude,
7857 noreply,
7858 body,
7859 json,
7860 } => cmd_mesh_broadcast(&kind, &scope, &exclude, noreply, &body, json),
7861 MeshCommand::Role { action } => cmd_mesh_role(action),
7862 MeshCommand::Route {
7863 role,
7864 strategy,
7865 exclude,
7866 kind,
7867 body,
7868 json,
7869 } => cmd_mesh_route(&role, &strategy, &exclude, &kind, &body, json),
7870 }
7871}
7872
7873fn cmd_mesh_route(
7878 role: &str,
7879 strategy: &str,
7880 exclude: &[String],
7881 kind: &str,
7882 body_arg: &str,
7883 as_json: bool,
7884) -> Result<()> {
7885 use std::time::Instant;
7886
7887 if !config::is_initialized()? {
7888 bail!("not initialized — run `wire init <handle>` first");
7889 }
7890 let strategy = strategy.to_ascii_lowercase();
7891 if !matches!(strategy.as_str(), "round-robin" | "first" | "random") {
7892 bail!("unknown strategy `{strategy}` — use round-robin | first | random");
7893 }
7894
7895 let state = config::read_relay_state()?;
7898 let pinned: std::collections::BTreeSet<String> = state["peers"]
7899 .as_object()
7900 .map(|m| m.keys().cloned().collect())
7901 .unwrap_or_default();
7902
7903 let exclude_set: std::collections::HashSet<&str> = exclude.iter().map(String::as_str).collect();
7904
7905 let sessions = crate::session::list_sessions()?;
7910 let mut candidates: Vec<(String, Option<String>)> = Vec::new(); for s in &sessions {
7912 let handle = match s.handle.as_ref() {
7913 Some(h) => h.clone(),
7914 None => continue,
7915 };
7916 if exclude_set.contains(handle.as_str()) {
7917 continue;
7918 }
7919 if !pinned.contains(&handle) {
7920 continue;
7921 }
7922 let card_path = s
7923 .home_dir
7924 .join("config")
7925 .join("wire")
7926 .join("agent-card.json");
7927 let card_role = std::fs::read(&card_path)
7928 .ok()
7929 .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
7930 .and_then(|c| {
7931 c.get("profile")
7932 .and_then(|p| p.get("role"))
7933 .and_then(Value::as_str)
7934 .map(str::to_string)
7935 });
7936 if card_role.as_deref() == Some(role) {
7937 candidates.push((handle, s.did.clone()));
7938 }
7939 }
7940
7941 candidates.sort_by(|a, b| a.0.cmp(&b.0));
7942 candidates.dedup_by(|a, b| a.0 == b.0);
7943
7944 if candidates.is_empty() {
7945 bail!(
7946 "no pinned sister with role=`{role}` (run `wire mesh role list` to see what's available)"
7947 );
7948 }
7949
7950 let chosen = match strategy.as_str() {
7951 "first" => candidates[0].clone(),
7952 "random" => {
7953 use rand::Rng;
7954 let idx = rand::thread_rng().gen_range(0..candidates.len());
7955 candidates[idx].clone()
7956 }
7957 "round-robin" => {
7958 let cursor_path = mesh_route_cursor_path()?;
7963 let mut cursors: std::collections::BTreeMap<String, String> =
7964 read_mesh_route_cursors(&cursor_path);
7965 let last = cursors.get(role).cloned();
7966 let pick = match last {
7967 None => candidates[0].clone(),
7968 Some(last_h) => candidates
7969 .iter()
7970 .find(|(h, _)| h.as_str() > last_h.as_str())
7971 .cloned()
7972 .unwrap_or_else(|| candidates[0].clone()),
7973 };
7974 cursors.insert(role.to_string(), pick.0.clone());
7975 write_mesh_route_cursors(&cursor_path, &cursors)?;
7976 pick
7977 }
7978 _ => unreachable!(),
7979 };
7980
7981 let (chosen_handle, _chosen_did) = chosen;
7982
7983 let body_value: Value = if body_arg == "-" {
7985 use std::io::Read;
7986 let mut raw = String::new();
7987 std::io::stdin()
7988 .read_to_string(&mut raw)
7989 .with_context(|| "reading body from stdin")?;
7990 serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
7991 } else if let Some(path) = body_arg.strip_prefix('@') {
7992 let raw =
7993 std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
7994 serde_json::from_str(&raw).unwrap_or(Value::String(raw))
7995 } else {
7996 Value::String(body_arg.to_string())
7997 };
7998
7999 let sk_seed = config::read_private_key()?;
8000 let card = config::read_agent_card()?;
8001 let did = card
8002 .get("did")
8003 .and_then(Value::as_str)
8004 .ok_or_else(|| anyhow!("agent-card missing did"))?
8005 .to_string();
8006 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
8007 let pk_b64 = card
8008 .get("verify_keys")
8009 .and_then(Value::as_object)
8010 .and_then(|m| m.values().next())
8011 .and_then(|v| v.get("key"))
8012 .and_then(Value::as_str)
8013 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
8014 let pk_bytes = crate::signing::b64decode(pk_b64)?;
8015
8016 let kind_id = parse_kind(kind)?;
8017 let now_iso = time::OffsetDateTime::now_utc()
8018 .format(&time::format_description::well_known::Rfc3339)
8019 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
8020
8021 let event = json!({
8022 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
8023 "timestamp": now_iso,
8024 "from": did,
8025 "to": format!("did:wire:{chosen_handle}"),
8026 "type": kind,
8027 "kind": kind_id,
8028 "body": json!({
8029 "content": body_value,
8030 "routed_via": {
8031 "role": role,
8032 "strategy": strategy,
8033 },
8034 }),
8035 });
8036 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)
8037 .map_err(|e| anyhow!("sign_message_v31 failed: {e:?}"))?;
8038 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
8039
8040 let line = serde_json::to_vec(&signed)?;
8041 config::append_outbox_record(&chosen_handle, &line)?;
8042
8043 let endpoints = crate::endpoints::peer_endpoints_in_priority_order(&state, &chosen_handle);
8044 if endpoints.is_empty() {
8045 bail!(
8046 "no reachable endpoint pinned for `{chosen_handle}` (the role matched, but we can't push)"
8047 );
8048 }
8049 let start = Instant::now();
8050 let mut delivered = false;
8051 let mut last_err: Option<String> = None;
8052 let mut via_scope: Option<String> = None;
8053 for ep in &endpoints {
8054 match crate::relay_client::post_event_to_endpoint(ep, &signed) {
8059 Ok(_) => {
8060 delivered = true;
8061 via_scope = Some(
8062 match ep.scope {
8063 crate::endpoints::EndpointScope::Local => "local",
8064 crate::endpoints::EndpointScope::Lan => "lan",
8065 crate::endpoints::EndpointScope::Uds => "uds",
8066 crate::endpoints::EndpointScope::Federation => "federation",
8067 }
8068 .to_string(),
8069 );
8070 break;
8071 }
8072 Err(e) => last_err = Some(format!("{e:#}")),
8073 }
8074 }
8075 let rtt_ms = start.elapsed().as_millis() as u64;
8076
8077 let summary = json!({
8078 "role": role,
8079 "strategy": strategy,
8080 "routed_to": chosen_handle,
8081 "event_id": event_id,
8082 "delivered": delivered,
8083 "delivered_via": via_scope,
8084 "rtt_ms": rtt_ms,
8085 "candidates": candidates.iter().map(|(h, _)| h.clone()).collect::<Vec<_>>(),
8086 "error": last_err,
8087 });
8088
8089 if as_json {
8090 println!("{}", serde_json::to_string(&summary)?);
8091 } else if delivered {
8092 let via = via_scope.as_deref().unwrap_or("?");
8093 println!("wire mesh route: {role} → {chosen_handle} ({rtt_ms}ms, {via})");
8094 } else {
8095 let err = last_err.as_deref().unwrap_or("no endpoints reachable");
8096 bail!("delivery to `{chosen_handle}` failed: {err}");
8097 }
8098 Ok(())
8099}
8100
8101fn mesh_route_cursor_path() -> Result<std::path::PathBuf> {
8102 Ok(config::state_dir()?.join("mesh-route-cursor.json"))
8103}
8104
8105fn read_mesh_route_cursors(path: &std::path::Path) -> std::collections::BTreeMap<String, String> {
8106 std::fs::read(path)
8107 .ok()
8108 .and_then(|b| serde_json::from_slice(&b).ok())
8109 .unwrap_or_default()
8110}
8111
8112fn write_mesh_route_cursors(
8113 path: &std::path::Path,
8114 cursors: &std::collections::BTreeMap<String, String>,
8115) -> Result<()> {
8116 if let Some(parent) = path.parent() {
8117 std::fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
8118 }
8119 let body = serde_json::to_vec_pretty(cursors)?;
8120 std::fs::write(path, body).with_context(|| format!("writing {path:?}"))?;
8121 Ok(())
8122}
8123
8124fn cmd_mesh_role(action: MeshRoleAction) -> Result<()> {
8129 match action {
8130 MeshRoleAction::Set { role, json } => {
8131 validate_role_tag(&role)?;
8132 let new_profile =
8133 crate::pair_profile::write_profile_field("role", Value::String(role.clone()))?;
8134 if json {
8135 println!(
8136 "{}",
8137 serde_json::to_string(&json!({
8138 "role": role,
8139 "profile": new_profile,
8140 }))?
8141 );
8142 } else {
8143 println!("self role = {role} (signed into agent-card)");
8144 }
8145 }
8146 MeshRoleAction::Get { peer, json } => {
8147 let (who, role) = match peer.as_deref() {
8148 None => {
8149 let card = config::read_agent_card()?;
8150 let role = card
8151 .get("profile")
8152 .and_then(|p| p.get("role"))
8153 .and_then(Value::as_str)
8154 .map(str::to_string);
8155 let who = card
8156 .get("did")
8157 .and_then(Value::as_str)
8158 .map(|d| crate::agent_card::display_handle_from_did(d).to_string())
8159 .unwrap_or_else(|| "self".to_string());
8160 (who, role)
8161 }
8162 Some(handle) => {
8163 let bare = crate::agent_card::bare_handle(handle).to_string();
8164 let trust = config::read_trust()?;
8165 let role = trust
8166 .get("agents")
8167 .and_then(|a| a.get(&bare))
8168 .and_then(|a| a.get("card"))
8169 .and_then(|c| c.get("profile"))
8170 .and_then(|p| p.get("role"))
8171 .and_then(Value::as_str)
8172 .map(str::to_string);
8173 (bare, role)
8174 }
8175 };
8176 if json {
8177 println!(
8178 "{}",
8179 serde_json::to_string(&json!({
8180 "handle": who,
8181 "role": role,
8182 }))?
8183 );
8184 } else {
8185 match role {
8186 Some(r) => println!("{who}: {r}"),
8187 None => println!("{who}: (unset)"),
8188 }
8189 }
8190 }
8191 MeshRoleAction::List { json } => {
8192 let mut self_did: Option<String> = None;
8193 if let Ok(card) = config::read_agent_card() {
8194 self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
8195 }
8196 let sessions = crate::session::list_sessions()?;
8197 let mut rows: Vec<Value> = Vec::new();
8198 for s in &sessions {
8199 let card_path = s
8200 .home_dir
8201 .join("config")
8202 .join("wire")
8203 .join("agent-card.json");
8204 let role = std::fs::read(&card_path)
8205 .ok()
8206 .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
8207 .and_then(|c| {
8208 c.get("profile")
8209 .and_then(|p| p.get("role"))
8210 .and_then(Value::as_str)
8211 .map(str::to_string)
8212 });
8213 let is_self = match (&self_did, &s.did) {
8214 (Some(a), Some(b)) => a == b,
8215 _ => false,
8216 };
8217 rows.push(json!({
8218 "name": s.name,
8219 "handle": s.handle,
8220 "role": role,
8221 "self": is_self,
8222 }));
8223 }
8224 rows.sort_by(|a, b| {
8225 a["name"]
8226 .as_str()
8227 .unwrap_or("")
8228 .cmp(b["name"].as_str().unwrap_or(""))
8229 });
8230 if json {
8231 println!("{}", serde_json::to_string(&json!({"sessions": rows}))?);
8232 } else if rows.is_empty() {
8233 println!("no sister sessions on this machine.");
8234 } else {
8235 println!("SISTER ROLES (this machine):");
8236 for r in &rows {
8237 let name = r["name"].as_str().unwrap_or("?");
8238 let role = r["role"].as_str().unwrap_or("(unset)");
8239 let marker = if r["self"].as_bool().unwrap_or(false) {
8240 " ← you"
8241 } else {
8242 ""
8243 };
8244 println!(" {name:<24} {role}{marker}");
8245 }
8246 }
8247 }
8248 MeshRoleAction::Clear { json } => {
8249 let new_profile = crate::pair_profile::write_profile_field("role", Value::Null)?;
8250 if json {
8251 println!(
8252 "{}",
8253 serde_json::to_string(&json!({
8254 "cleared": true,
8255 "profile": new_profile,
8256 }))?
8257 );
8258 } else {
8259 println!("self role cleared");
8260 }
8261 }
8262 }
8263 Ok(())
8264}
8265
8266fn validate_role_tag(role: &str) -> Result<()> {
8271 if role.is_empty() {
8272 bail!("role must not be empty (use `wire mesh role --clear` to unset)");
8273 }
8274 if role.len() > 32 {
8275 bail!("role too long ({} chars; max 32)", role.len());
8276 }
8277 for c in role.chars() {
8278 if !(c.is_ascii_alphanumeric() || c == '-' || c == '_') {
8279 bail!("role contains illegal char {c:?} (allowed: A-Z a-z 0-9 - _)");
8280 }
8281 }
8282 Ok(())
8283}
8284
8285fn cmd_mesh_broadcast(
8305 kind: &str,
8306 scope_str: &str,
8307 exclude: &[String],
8308 _noreply: bool,
8309 body_arg: &str,
8310 as_json: bool,
8311) -> Result<()> {
8312 use std::time::Instant;
8313
8314 if !config::is_initialized()? {
8315 bail!("not initialized — run `wire init <handle>` first");
8316 }
8317
8318 let scope = match scope_str {
8319 "local" => crate::endpoints::EndpointScope::Local,
8320 "federation" => crate::endpoints::EndpointScope::Federation,
8321 "both" => {
8322 crate::endpoints::EndpointScope::Local
8326 }
8327 other => bail!("unknown scope `{other}` — use local | federation | both"),
8328 };
8329 let any_scope = scope_str == "both";
8330
8331 let state = config::read_relay_state()?;
8332 let peers = state["peers"].as_object().cloned().unwrap_or_default();
8333 if peers.is_empty() {
8334 bail!("no peers pinned — run `wire accept <invite-url>` or `wire pair-accept` first");
8335 }
8336
8337 let exclude_set: std::collections::HashSet<&str> = exclude.iter().map(String::as_str).collect();
8338
8339 struct Target {
8343 handle: String,
8344 endpoints: Vec<crate::endpoints::Endpoint>,
8345 }
8346 let mut targets: Vec<Target> = Vec::new();
8347 let mut skipped_wrong_scope: Vec<String> = Vec::new();
8348 let mut skipped_excluded: Vec<String> = Vec::new();
8349 for handle in peers.keys() {
8350 if exclude_set.contains(handle.as_str()) {
8351 skipped_excluded.push(handle.clone());
8352 continue;
8353 }
8354 let ordered = crate::endpoints::peer_endpoints_in_priority_order(&state, handle);
8355 let filtered: Vec<crate::endpoints::Endpoint> = ordered
8356 .into_iter()
8357 .filter(|ep| any_scope || ep.scope == scope)
8358 .collect();
8359 if filtered.is_empty() {
8360 skipped_wrong_scope.push(handle.clone());
8361 continue;
8362 }
8363 targets.push(Target {
8364 handle: handle.clone(),
8365 endpoints: filtered,
8366 });
8367 }
8368
8369 if targets.is_empty() {
8370 bail!(
8371 "no peers matched scope=`{scope_str}` after exclude filter ({} excluded, {} wrong-scope)",
8372 skipped_excluded.len(),
8373 skipped_wrong_scope.len()
8374 );
8375 }
8376
8377 let sk_seed = config::read_private_key()?;
8379 let card = config::read_agent_card()?;
8380 let did = card
8381 .get("did")
8382 .and_then(Value::as_str)
8383 .ok_or_else(|| anyhow!("agent-card missing did"))?
8384 .to_string();
8385 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
8386 let pk_b64 = card
8387 .get("verify_keys")
8388 .and_then(Value::as_object)
8389 .and_then(|m| m.values().next())
8390 .and_then(|v| v.get("key"))
8391 .and_then(Value::as_str)
8392 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
8393 let pk_bytes = crate::signing::b64decode(pk_b64)?;
8394
8395 let body_value: Value = if body_arg == "-" {
8396 use std::io::Read;
8397 let mut raw = String::new();
8398 std::io::stdin()
8399 .read_to_string(&mut raw)
8400 .with_context(|| "reading body from stdin")?;
8401 serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
8402 } else if let Some(path) = body_arg.strip_prefix('@') {
8403 let raw =
8404 std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
8405 serde_json::from_str(&raw).unwrap_or(Value::String(raw))
8406 } else {
8407 Value::String(body_arg.to_string())
8408 };
8409
8410 let kind_id = parse_kind(kind)?;
8411 let now_iso = time::OffsetDateTime::now_utc()
8412 .format(&time::format_description::well_known::Rfc3339)
8413 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
8414
8415 let broadcast_id = generate_broadcast_id();
8416 let target_count = targets.len();
8417
8418 let mut signed_per_peer: Vec<(String, Vec<crate::endpoints::Endpoint>, Value, String)> =
8422 Vec::with_capacity(targets.len());
8423 for t in &targets {
8424 let body = json!({
8425 "content": body_value,
8426 "broadcast_id": broadcast_id,
8427 "broadcast_target_count": target_count,
8428 });
8429 let event = json!({
8430 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
8431 "timestamp": now_iso,
8432 "from": did,
8433 "to": format!("did:wire:{}", t.handle),
8434 "type": kind,
8435 "kind": kind_id,
8436 "body": body,
8437 });
8438 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)
8439 .map_err(|e| anyhow!("sign_message_v31 failed for `{}`: {e:?}", t.handle))?;
8440 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
8441 signed_per_peer.push((t.handle.clone(), t.endpoints.clone(), signed, event_id));
8442 }
8443
8444 for (peer, _, signed, _) in &signed_per_peer {
8448 let line = serde_json::to_vec(signed)?;
8449 config::append_outbox_record(peer, &line)?;
8450 }
8451
8452 use std::sync::mpsc;
8456 let (tx, rx) = mpsc::channel::<Value>();
8457 std::thread::scope(|s| {
8458 for (peer, endpoints, signed, event_id) in &signed_per_peer {
8459 let tx = tx.clone();
8460 let peer = peer.clone();
8461 let event_id = event_id.clone();
8462 let endpoints = endpoints.clone();
8463 let signed = signed.clone();
8464 s.spawn(move || {
8465 let start = Instant::now();
8466 let mut delivered = false;
8467 let mut last_err: Option<String> = None;
8468 let mut delivered_via: Option<String> = None;
8469 for ep in &endpoints {
8470 match crate::relay_client::post_event_to_endpoint(ep, &signed) {
8475 Ok(_) => {
8476 delivered = true;
8477 delivered_via = Some(
8478 match ep.scope {
8479 crate::endpoints::EndpointScope::Local => "local",
8480 crate::endpoints::EndpointScope::Lan => "lan",
8481 crate::endpoints::EndpointScope::Uds => "uds",
8482 crate::endpoints::EndpointScope::Federation => "federation",
8483 }
8484 .to_string(),
8485 );
8486 break;
8487 }
8488 Err(e) => last_err = Some(format!("{e:#}")),
8489 }
8490 }
8491 let rtt_ms = start.elapsed().as_millis() as u64;
8492 let _ = tx.send(json!({
8493 "peer": peer,
8494 "event_id": event_id,
8495 "delivered": delivered,
8496 "delivered_via": delivered_via,
8497 "rtt_ms": rtt_ms,
8498 "error": last_err,
8499 }));
8500 });
8501 }
8502 });
8503 drop(tx);
8504
8505 let mut results: Vec<Value> = rx.iter().collect();
8506 results.sort_by(|a, b| {
8507 a["peer"]
8508 .as_str()
8509 .unwrap_or("")
8510 .cmp(b["peer"].as_str().unwrap_or(""))
8511 });
8512
8513 let delivered = results
8514 .iter()
8515 .filter(|r| r["delivered"].as_bool().unwrap_or(false))
8516 .count();
8517 let failed = results.len() - delivered;
8518
8519 let summary = json!({
8520 "broadcast_id": broadcast_id,
8521 "kind": kind,
8522 "scope": scope_str,
8523 "target_count": target_count,
8524 "delivered": delivered,
8525 "failed": failed,
8526 "skipped_excluded": skipped_excluded,
8527 "skipped_wrong_scope": skipped_wrong_scope,
8528 "results": results,
8529 });
8530
8531 if as_json {
8532 println!("{}", serde_json::to_string(&summary)?);
8533 return Ok(());
8534 }
8535
8536 println!("wire mesh broadcast: scope={scope_str} → {target_count} pinned peer(s)");
8537 for r in &results {
8538 let peer = r["peer"].as_str().unwrap_or("?");
8539 let delivered = r["delivered"].as_bool().unwrap_or(false);
8540 let rtt = r["rtt_ms"].as_u64().unwrap_or(0);
8541 let via = r["delivered_via"].as_str().unwrap_or("");
8542 if delivered {
8543 println!(" {peer:<24} ✓ delivered ({rtt}ms, {via})");
8544 } else {
8545 let err = r["error"].as_str().unwrap_or("?");
8546 println!(" {peer:<24} ✗ failed — {err}");
8547 }
8548 }
8549 if !skipped_excluded.is_empty() {
8550 println!(" excluded: {}", skipped_excluded.join(", "));
8551 }
8552 if !skipped_wrong_scope.is_empty() {
8553 println!(
8554 " skipped (wrong scope): {}",
8555 skipped_wrong_scope.join(", ")
8556 );
8557 }
8558 println!("broadcast_id: {broadcast_id}");
8559 Ok(())
8560}
8561
8562fn generate_broadcast_id() -> String {
8566 use rand::RngCore;
8567 let mut buf = [0u8; 16];
8568 rand::thread_rng().fill_bytes(&mut buf);
8569 let h = hex::encode(buf);
8570 format!(
8571 "{}-{}-{}-{}-{}",
8572 &h[0..8],
8573 &h[8..12],
8574 &h[12..16],
8575 &h[16..20],
8576 &h[20..32],
8577 )
8578}
8579
8580fn cmd_session(cmd: SessionCommand) -> Result<()> {
8581 match cmd {
8582 SessionCommand::New {
8583 name,
8584 relay,
8585 with_local,
8586 local_relay,
8587 with_lan,
8588 lan_relay,
8589 with_uds,
8590 uds_socket,
8591 no_daemon,
8592 local_only,
8593 json,
8594 } => cmd_session_new(
8595 name.as_deref(),
8596 &relay,
8597 with_local,
8598 &local_relay,
8599 with_lan,
8600 lan_relay.as_deref(),
8601 with_uds,
8602 uds_socket.as_deref(),
8603 no_daemon,
8604 local_only,
8605 json,
8606 ),
8607 SessionCommand::List { json } => cmd_session_list(json),
8608 SessionCommand::ListLocal { json } => cmd_session_list_local(json),
8609 SessionCommand::PairAllLocal {
8610 settle_secs,
8611 federation_relay,
8612 json,
8613 } => cmd_session_pair_all_local(settle_secs, &federation_relay, json),
8614 SessionCommand::MeshStatus { stale_secs, json } => {
8615 cmd_session_mesh_status(stale_secs, json)
8616 }
8617 SessionCommand::Env { name, json } => cmd_session_env(name.as_deref(), json),
8618 SessionCommand::Current { json } => cmd_session_current(json),
8619 SessionCommand::Bind { name, json } => cmd_session_bind(name.as_deref(), json),
8620 SessionCommand::Destroy { name, force, json } => cmd_session_destroy(&name, force, json),
8621 }
8622}
8623
8624fn cmd_session_bind(name_arg: Option<&str>, json: bool) -> Result<()> {
8625 let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
8626 let cwd_str = cwd.to_string_lossy().into_owned();
8627
8628 let resolved_name = match name_arg {
8629 Some(n) => crate::session::sanitize_name(n),
8630 None => crate::session::sanitize_name(
8631 cwd.file_name()
8632 .and_then(|s| s.to_str())
8633 .ok_or_else(|| anyhow!("cwd has no basename to derive session name from"))?,
8634 ),
8635 };
8636
8637 let session_home = crate::session::session_dir(&resolved_name)?;
8638 if !session_home.exists() {
8639 bail!(
8640 "session `{resolved_name}` does not exist (looked at {}). Create it first with `wire session new {resolved_name}` or pass an existing name.",
8641 session_home.display()
8642 );
8643 }
8644
8645 let prior = crate::session::read_registry()
8646 .ok()
8647 .and_then(|r| r.by_cwd.get(&cwd_str).cloned());
8648 if prior.as_deref() == Some(resolved_name.as_str()) {
8649 if json {
8650 println!(
8651 "{}",
8652 serde_json::to_string(&json!({
8653 "cwd": cwd_str,
8654 "session": resolved_name,
8655 "changed": false,
8656 }))?
8657 );
8658 } else {
8659 println!("cwd `{cwd_str}` already bound to session `{resolved_name}` (no change)");
8660 }
8661 return Ok(());
8662 }
8663 if let Some(prior_name) = &prior {
8664 eprintln!(
8665 "wire session bind: cwd `{cwd_str}` was bound to `{prior_name}`; overwriting with `{resolved_name}`."
8666 );
8667 }
8668
8669 crate::session::update_registry(|reg| {
8670 reg.by_cwd.insert(cwd_str.clone(), resolved_name.clone());
8671 Ok(())
8672 })?;
8673
8674 if json {
8675 println!(
8676 "{}",
8677 serde_json::to_string(&json!({
8678 "cwd": cwd_str,
8679 "session": resolved_name,
8680 "changed": true,
8681 "previous": prior,
8682 }))?
8683 );
8684 } else {
8685 println!("bound cwd `{cwd_str}` → session `{resolved_name}`");
8686 println!("(next `wire` invocation from this cwd will auto-detect into this session)");
8687 }
8688 Ok(())
8689}
8690
8691fn resolve_session_name(name: Option<&str>) -> Result<String> {
8692 if let Some(n) = name {
8693 return Ok(crate::session::sanitize_name(n));
8694 }
8695 let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
8696 let registry = crate::session::read_registry().unwrap_or_default();
8697 Ok(crate::session::derive_name_from_cwd(&cwd, ®istry))
8698}
8699
8700#[allow(clippy::too_many_arguments)] fn cmd_session_new(
8704 name_arg: Option<&str>,
8705 relay: &str,
8706 with_local: bool,
8707 local_relay: &str,
8708 with_lan: bool,
8709 lan_relay: Option<&str>,
8710 with_uds: bool,
8711 uds_socket: Option<&std::path::Path>,
8712 no_daemon: bool,
8713 local_only: bool,
8714 as_json: bool,
8715) -> Result<()> {
8716 let with_local = with_local || local_only;
8719 if with_lan && lan_relay.is_none() {
8721 bail!("--with-lan requires --lan-relay <url> (e.g. http://192.168.1.50:8771)");
8722 }
8723 if with_uds && uds_socket.is_none() {
8725 bail!("--with-uds requires --uds-socket <path> (e.g. /tmp/wire.sock)");
8726 }
8727 let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
8728 let mut registry = crate::session::read_registry().unwrap_or_default();
8729 let name = match name_arg {
8730 Some(n) => crate::session::sanitize_name(n),
8731 None => crate::session::derive_name_from_cwd(&cwd, ®istry),
8732 };
8733 let session_home = crate::session::session_dir(&name)?;
8734
8735 let already_exists = session_home.exists()
8736 && session_home
8737 .join("config")
8738 .join("wire")
8739 .join("agent-card.json")
8740 .exists();
8741 if already_exists {
8742 registry
8746 .by_cwd
8747 .insert(cwd.to_string_lossy().into_owned(), name.clone());
8748 crate::session::write_registry(®istry)?;
8749 let info = render_session_info(&name, &session_home, &cwd)?;
8750 emit_session_new_result(&info, "already_exists", as_json)?;
8751 if !no_daemon {
8752 ensure_session_daemon(&session_home)?;
8753 }
8754 return Ok(());
8755 }
8756
8757 std::fs::create_dir_all(&session_home)
8758 .with_context(|| format!("creating session dir {session_home:?}"))?;
8759
8760 let init_args: Vec<&str> = if local_only {
8769 vec!["init", &name, "--offline"]
8770 } else {
8771 vec!["init", &name, "--relay", relay]
8772 };
8773 let init_status = run_wire_with_home(&session_home, &init_args)?;
8774 if !init_status.success() {
8775 let how = if local_only {
8776 format!("`wire init {name}` (local-only)")
8777 } else {
8778 format!("`wire init {name} --relay {relay}`")
8779 };
8780 bail!("{how} failed inside session dir {session_home:?}");
8781 }
8782
8783 let effective_handle = if local_only {
8788 name.clone()
8789 } else {
8790 let mut claim_attempt = 0u32;
8791 let mut effective = name.clone();
8792 loop {
8793 claim_attempt += 1;
8794 let status =
8795 run_wire_with_home(&session_home, &["claim", &effective, "--relay", relay])?;
8796 if status.success() {
8797 break;
8798 }
8799 if claim_attempt >= 5 {
8800 bail!(
8801 "5 failed attempts to claim a handle on {relay} for session {name}. \
8802 Try `wire session destroy {name} --force` and re-run with a different name, \
8803 or use `--local-only` if you don't need a federation address."
8804 );
8805 }
8806 let attempt_path = cwd.join(format!("__attempt_{claim_attempt}"));
8807 let suffix = crate::session::derive_name_from_cwd(&attempt_path, ®istry);
8808 let token = suffix
8809 .rsplit('-')
8810 .next()
8811 .filter(|t| t.len() == 4)
8812 .map(str::to_string)
8813 .unwrap_or_else(|| format!("{claim_attempt}"));
8814 effective = format!("{name}-{token}");
8815 }
8816 effective
8817 };
8818
8819 registry
8822 .by_cwd
8823 .insert(cwd.to_string_lossy().into_owned(), name.clone());
8824 crate::session::write_registry(®istry)?;
8825
8826 if with_local {
8837 try_allocate_local_slot(&session_home, &effective_handle, relay, local_relay);
8838 if local_only {
8839 let relay_state_path = session_home.join("config").join("wire").join("relay.json");
8844 let state: Value = std::fs::read(&relay_state_path)
8845 .ok()
8846 .and_then(|b| serde_json::from_slice(&b).ok())
8847 .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
8848 let endpoints = crate::endpoints::self_endpoints(&state);
8849 let has_local = endpoints
8850 .iter()
8851 .any(|e| e.scope == crate::endpoints::EndpointScope::Local);
8852 if !has_local {
8853 bail!(
8854 "--local-only requested but local-relay probe at {local_relay} failed — \
8855 ensure the local relay is running (`wire service install --local-relay`), \
8856 then re-run `wire session new {name} --local-only`."
8857 );
8858 }
8859 }
8860 }
8861
8862 if with_lan && let Some(lan_url) = lan_relay {
8866 try_allocate_lan_slot(&session_home, &effective_handle, lan_url);
8867 }
8868 if with_uds && let Some(socket_path) = uds_socket {
8870 try_allocate_uds_slot(&session_home, &effective_handle, socket_path);
8871 }
8872
8873 if !no_daemon {
8874 ensure_session_daemon(&session_home)?;
8875 }
8876
8877 let info = render_session_info(&name, &session_home, &cwd)?;
8878 emit_session_new_result(&info, "created", as_json)
8879}
8880
8881#[cfg(unix)]
8891fn try_allocate_uds_slot(
8892 session_home: &std::path::Path,
8893 handle: &str,
8894 uds_socket: &std::path::Path,
8895) {
8896 let healthz = match crate::relay_client::uds_request(uds_socket, "GET", "/healthz", &[], b"") {
8899 Ok((200, _)) => true,
8900 Ok((status, body)) => {
8901 eprintln!(
8902 "wire session new: UDS relay probe at {uds_socket:?} returned {status} ({}) — not publishing UDS endpoint",
8903 String::from_utf8_lossy(&body)
8904 );
8905 return;
8906 }
8907 Err(e) => {
8908 eprintln!(
8909 "wire session new: UDS relay at {uds_socket:?} unreachable ({e:#}) — \
8910 not publishing UDS endpoint. Start one with `wire relay-server --uds <path>`."
8911 );
8912 return;
8913 }
8914 };
8915 if !healthz {
8916 return;
8917 }
8918
8919 let alloc_body = serde_json::json!({"handle": handle}).to_string();
8921 let (status, body) = match crate::relay_client::uds_request(
8922 uds_socket,
8923 "POST",
8924 "/v1/slot/allocate",
8925 &[("Content-Type", "application/json")],
8926 alloc_body.as_bytes(),
8927 ) {
8928 Ok(r) => r,
8929 Err(e) => {
8930 eprintln!(
8931 "wire session new: UDS relay slot allocation request failed: {e:#} — not publishing UDS endpoint"
8932 );
8933 return;
8934 }
8935 };
8936 if status >= 300 {
8937 eprintln!(
8938 "wire session new: UDS relay slot allocation returned {status} ({}) — not publishing UDS endpoint",
8939 String::from_utf8_lossy(&body)
8940 );
8941 return;
8942 }
8943 let alloc: crate::relay_client::AllocateResponse = match serde_json::from_slice(&body) {
8944 Ok(a) => a,
8945 Err(e) => {
8946 eprintln!("wire session new: UDS relay returned unparseable allocate response: {e:#}");
8947 return;
8948 }
8949 };
8950
8951 let state_path = session_home.join("config").join("wire").join("relay.json");
8952 let mut state: serde_json::Value = std::fs::read(&state_path)
8953 .ok()
8954 .and_then(|b| serde_json::from_slice(&b).ok())
8955 .unwrap_or_else(|| serde_json::json!({}));
8956
8957 let mut endpoints: Vec<crate::endpoints::Endpoint> = state
8958 .get("self")
8959 .and_then(|s| s.get("endpoints"))
8960 .and_then(|e| e.as_array())
8961 .map(|arr| {
8962 arr.iter()
8963 .filter_map(|v| {
8964 serde_json::from_value::<crate::endpoints::Endpoint>(v.clone()).ok()
8965 })
8966 .collect()
8967 })
8968 .unwrap_or_default();
8969 endpoints.push(crate::endpoints::Endpoint::uds(
8970 format!("unix://{}", uds_socket.display()),
8971 alloc.slot_id.clone(),
8972 alloc.slot_token.clone(),
8973 ));
8974
8975 let self_obj = state
8976 .as_object_mut()
8977 .expect("relay_state root is an object")
8978 .entry("self")
8979 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
8980 if !self_obj.is_object() {
8981 *self_obj = serde_json::Value::Object(serde_json::Map::new());
8982 }
8983 if let Some(obj) = self_obj.as_object_mut() {
8984 obj.insert(
8985 "endpoints".into(),
8986 serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
8987 );
8988 }
8989 if let Err(e) = std::fs::write(
8990 &state_path,
8991 serde_json::to_vec_pretty(&state).expect("relay_state serializable"),
8992 ) {
8993 eprintln!("wire session new: failed to write {state_path:?}: {e}");
8994 return;
8995 }
8996 eprintln!(
8997 "wire session new: UDS slot allocated on unix://{} (slot_id={}) — sister sessions will see this endpoint in your agent-card",
8998 uds_socket.display(),
8999 alloc.slot_id
9000 );
9001}
9002
9003#[cfg(not(unix))]
9004fn try_allocate_uds_slot(
9005 _session_home: &std::path::Path,
9006 _handle: &str,
9007 _uds_socket: &std::path::Path,
9008) {
9009 eprintln!(
9010 "wire session new: --with-uds is Unix-only (Windows lacks AF_UNIX in tokio/reqwest); ignoring"
9011 );
9012}
9013
9014fn try_allocate_lan_slot(session_home: &std::path::Path, handle: &str, lan_relay: &str) {
9024 let probe = match crate::relay_client::build_blocking_client(Some(
9025 std::time::Duration::from_millis(500),
9026 )) {
9027 Ok(c) => c,
9028 Err(e) => {
9029 eprintln!("wire session new: cannot build LAN probe client for {lan_relay}: {e:#}");
9030 return;
9031 }
9032 };
9033 let healthz_url = format!("{}/healthz", lan_relay.trim_end_matches('/'));
9034 match probe.get(&healthz_url).send() {
9035 Ok(resp) if resp.status().is_success() => {}
9036 Ok(resp) => {
9037 eprintln!(
9038 "wire session new: LAN relay probe at {healthz_url} returned {} — not publishing LAN endpoint",
9039 resp.status()
9040 );
9041 return;
9042 }
9043 Err(e) => {
9044 eprintln!(
9045 "wire session new: LAN relay at {lan_relay} unreachable ({}) — not publishing LAN endpoint. \
9046 Start one on the LAN-bound interface with `wire relay-server --bind <LAN-IP>:8771 --local-only`.",
9047 crate::relay_client::format_transport_error(&anyhow::Error::new(e))
9048 );
9049 return;
9050 }
9051 };
9052
9053 let lan_client = crate::relay_client::RelayClient::new(lan_relay);
9054 let alloc = match lan_client.allocate_slot(Some(handle)) {
9055 Ok(a) => a,
9056 Err(e) => {
9057 eprintln!(
9058 "wire session new: LAN relay slot allocation failed: {e:#} — not publishing LAN endpoint"
9059 );
9060 return;
9061 }
9062 };
9063
9064 let state_path = session_home.join("config").join("wire").join("relay.json");
9065 let mut state: serde_json::Value = std::fs::read(&state_path)
9066 .ok()
9067 .and_then(|b| serde_json::from_slice(&b).ok())
9068 .unwrap_or_else(|| serde_json::json!({}));
9069
9070 let mut endpoints: Vec<crate::endpoints::Endpoint> = state
9073 .get("self")
9074 .and_then(|s| s.get("endpoints"))
9075 .and_then(|e| e.as_array())
9076 .map(|arr| {
9077 arr.iter()
9078 .filter_map(|v| {
9079 serde_json::from_value::<crate::endpoints::Endpoint>(v.clone()).ok()
9080 })
9081 .collect()
9082 })
9083 .unwrap_or_default();
9084 endpoints.push(crate::endpoints::Endpoint::lan(
9085 lan_relay.trim_end_matches('/').to_string(),
9086 alloc.slot_id.clone(),
9087 alloc.slot_token.clone(),
9088 ));
9089
9090 let self_obj = state
9091 .as_object_mut()
9092 .expect("relay_state root is an object")
9093 .entry("self")
9094 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
9095 if !self_obj.is_object() {
9096 *self_obj = serde_json::Value::Object(serde_json::Map::new());
9097 }
9098 if let Some(obj) = self_obj.as_object_mut() {
9099 obj.insert(
9100 "endpoints".into(),
9101 serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
9102 );
9103 }
9104 if let Err(e) = std::fs::write(
9105 &state_path,
9106 serde_json::to_vec_pretty(&state).expect("relay_state serializable"),
9107 ) {
9108 eprintln!("wire session new: failed to write {state_path:?}: {e}");
9109 return;
9110 }
9111 eprintln!(
9112 "wire session new: LAN slot allocated on {lan_relay} (slot_id={}) — peers will see this endpoint in your agent-card",
9113 alloc.slot_id
9114 );
9115}
9116
9117fn try_allocate_local_slot(
9125 session_home: &std::path::Path,
9126 handle: &str,
9127 _federation_relay: &str,
9128 local_relay: &str,
9129) {
9130 let probe = match crate::relay_client::build_blocking_client(Some(
9133 std::time::Duration::from_millis(500),
9134 )) {
9135 Ok(c) => c,
9136 Err(e) => {
9137 eprintln!("wire session new: cannot build probe client for {local_relay}: {e:#}");
9138 return;
9139 }
9140 };
9141 let healthz_url = format!("{}/healthz", local_relay.trim_end_matches('/'));
9142 match probe.get(&healthz_url).send() {
9143 Ok(resp) if resp.status().is_success() => {}
9144 Ok(resp) => {
9145 eprintln!(
9146 "wire session new: local relay probe at {healthz_url} returned {} — staying federation-only",
9147 resp.status()
9148 );
9149 return;
9150 }
9151 Err(e) => {
9152 eprintln!(
9153 "wire session new: local relay at {local_relay} unreachable ({}) — staying federation-only. \
9154 Start one with `wire relay-server --bind 127.0.0.1:8771 --local-only`.",
9155 crate::relay_client::format_transport_error(&anyhow::Error::new(e))
9156 );
9157 return;
9158 }
9159 };
9160
9161 let local_client = crate::relay_client::RelayClient::new(local_relay);
9163 let alloc = match local_client.allocate_slot(Some(handle)) {
9164 Ok(a) => a,
9165 Err(e) => {
9166 eprintln!(
9167 "wire session new: local relay slot allocation failed: {e:#} — staying federation-only"
9168 );
9169 return;
9170 }
9171 };
9172
9173 let state_path = session_home.join("config").join("wire").join("relay.json");
9188 let mut state: serde_json::Value = std::fs::read(&state_path)
9189 .ok()
9190 .and_then(|b| serde_json::from_slice(&b).ok())
9191 .unwrap_or_else(|| serde_json::json!({}));
9192 let fed_endpoint = state.get("self").and_then(|s| {
9195 let url = s.get("relay_url").and_then(serde_json::Value::as_str)?;
9196 let slot_id = s.get("slot_id").and_then(serde_json::Value::as_str)?;
9197 let slot_token = s.get("slot_token").and_then(serde_json::Value::as_str)?;
9198 Some(crate::endpoints::Endpoint::federation(
9199 url.to_string(),
9200 slot_id.to_string(),
9201 slot_token.to_string(),
9202 ))
9203 });
9204
9205 let local_endpoint = crate::endpoints::Endpoint::local(
9206 local_relay.trim_end_matches('/').to_string(),
9207 alloc.slot_id.clone(),
9208 alloc.slot_token.clone(),
9209 );
9210
9211 let mut endpoints: Vec<crate::endpoints::Endpoint> = Vec::new();
9212 if let Some(f) = fed_endpoint.clone() {
9213 endpoints.push(f);
9214 }
9215 endpoints.push(local_endpoint);
9216
9217 let (legacy_relay, legacy_slot_id, legacy_slot_token) = match fed_endpoint.clone() {
9227 Some(f) => (f.relay_url, f.slot_id, f.slot_token),
9228 None => (
9229 local_relay.trim_end_matches('/').to_string(),
9230 alloc.slot_id.clone(),
9231 alloc.slot_token.clone(),
9232 ),
9233 };
9234 let self_obj = state
9235 .as_object_mut()
9236 .expect("relay_state root is an object")
9237 .entry("self")
9238 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
9239 if !self_obj.is_object() {
9242 *self_obj = serde_json::Value::Object(serde_json::Map::new());
9243 }
9244 if let Some(obj) = self_obj.as_object_mut() {
9245 obj.insert("relay_url".into(), serde_json::Value::String(legacy_relay));
9246 obj.insert("slot_id".into(), serde_json::Value::String(legacy_slot_id));
9247 obj.insert(
9248 "slot_token".into(),
9249 serde_json::Value::String(legacy_slot_token),
9250 );
9251 obj.insert(
9252 "endpoints".into(),
9253 serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
9254 );
9255 }
9256
9257 if let Err(e) = std::fs::write(
9258 &state_path,
9259 serde_json::to_vec_pretty(&state).unwrap_or_default(),
9260 ) {
9261 eprintln!(
9262 "wire session new: persisting dual-slot relay_state at {state_path:?} failed: {e}"
9263 );
9264 return;
9265 }
9266 eprintln!(
9267 "wire session new: local slot allocated on {local_relay} (slot_id={})",
9268 alloc.slot_id
9269 );
9270}
9271
9272fn render_session_info(
9273 name: &str,
9274 session_home: &std::path::Path,
9275 cwd: &std::path::Path,
9276) -> Result<serde_json::Value> {
9277 let card_path = session_home
9278 .join("config")
9279 .join("wire")
9280 .join("agent-card.json");
9281 let (did, handle) = if card_path.exists() {
9282 let card: Value = serde_json::from_slice(&std::fs::read(&card_path)?)?;
9283 let did = card
9284 .get("did")
9285 .and_then(Value::as_str)
9286 .unwrap_or("")
9287 .to_string();
9288 let handle = card
9289 .get("handle")
9290 .and_then(Value::as_str)
9291 .map(str::to_string)
9292 .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
9293 (did, handle)
9294 } else {
9295 (String::new(), String::new())
9296 };
9297 Ok(json!({
9298 "name": name,
9299 "home_dir": session_home.to_string_lossy(),
9300 "cwd": cwd.to_string_lossy(),
9301 "did": did,
9302 "handle": handle,
9303 "export": format!("export WIRE_HOME={}", session_home.to_string_lossy()),
9304 }))
9305}
9306
9307fn emit_session_new_result(info: &serde_json::Value, status: &str, as_json: bool) -> Result<()> {
9308 if as_json {
9309 let mut obj = info.clone();
9310 obj["status"] = json!(status);
9311 println!("{}", serde_json::to_string(&obj)?);
9312 } else {
9313 let name = info["name"].as_str().unwrap_or("?");
9314 let handle = info["handle"].as_str().unwrap_or("?");
9315 let home = info["home_dir"].as_str().unwrap_or("?");
9316 let did = info["did"].as_str().unwrap_or("?");
9317 let export = info["export"].as_str().unwrap_or("?");
9318 let prefix = if status == "already_exists" {
9319 "session already exists (re-registered cwd)"
9320 } else {
9321 "session created"
9322 };
9323 println!(
9324 "{prefix}\n name: {name}\n handle: {handle}\n did: {did}\n home: {home}\n\nactivate with:\n {export}"
9325 );
9326 }
9327 Ok(())
9328}
9329
9330fn run_wire_with_home(
9331 session_home: &std::path::Path,
9332 args: &[&str],
9333) -> Result<std::process::ExitStatus> {
9334 let bin = std::env::current_exe().with_context(|| "locating self exe")?;
9335 let status = std::process::Command::new(&bin)
9336 .env("WIRE_HOME", session_home)
9337 .env_remove("RUST_LOG")
9338 .env("WIRE_AUTO_INIT", "0")
9341 .args(args)
9342 .status()
9343 .with_context(|| format!("spawning `wire {}`", args.join(" ")))?;
9344 Ok(status)
9345}
9346
9347pub fn maybe_auto_init_cwd_session(label: &str) {
9366 if std::env::var("WIRE_HOME").is_ok() {
9367 return; }
9369 if std::env::var("WIRE_AUTO_INIT").as_deref() == Ok("0") {
9370 return; }
9372 let cwd = match std::env::current_dir() {
9373 Ok(c) => c,
9374 Err(_) => return,
9375 };
9376 if crate::session::detect_session_wire_home(&cwd).is_some() {
9379 return;
9380 }
9381
9382 use fs2::FileExt;
9399 let sessions_root = match crate::session::sessions_root() {
9400 Ok(r) => r,
9401 Err(_) => return,
9402 };
9403 if let Err(e) = std::fs::create_dir_all(&sessions_root) {
9404 eprintln!("wire {label}: auto-init: failed to create sessions root {sessions_root:?}: {e}");
9405 return;
9406 }
9407 let lock_path = sessions_root.join(".auto-init.lock");
9408 let lock_file = match std::fs::OpenOptions::new()
9409 .create(true)
9410 .truncate(false)
9411 .read(true)
9412 .write(true)
9413 .open(&lock_path)
9414 {
9415 Ok(f) => f,
9416 Err(e) => {
9417 eprintln!(
9418 "wire {label}: auto-init: cannot open lockfile {lock_path:?}: {e} — falling back to default identity"
9419 );
9420 return;
9421 }
9422 };
9423 if let Err(e) = lock_file.lock_exclusive() {
9424 eprintln!(
9425 "wire {label}: auto-init: flock {lock_path:?} failed: {e} — falling back to default identity"
9426 );
9427 return;
9428 }
9429 let registry = crate::session::read_registry().unwrap_or_default();
9434 let name = crate::session::derive_name_from_cwd(&cwd, ®istry);
9435 let session_home = match crate::session::session_dir(&name) {
9436 Ok(h) => h,
9437 Err(_) => {
9438 let _ = fs2::FileExt::unlock(&lock_file);
9439 return;
9440 }
9441 };
9442 let agent_card_path = session_home
9443 .join("config")
9444 .join("wire")
9445 .join("agent-card.json");
9446 let needs_init = !agent_card_path.exists();
9447
9448 if needs_init {
9449 if let Err(e) = std::fs::create_dir_all(&session_home) {
9450 eprintln!(
9451 "wire {label}: auto-init: failed to create session dir {session_home:?}: {e}"
9452 );
9453 let _ = fs2::FileExt::unlock(&lock_file);
9454 return;
9455 }
9456 match run_wire_with_home(&session_home, &["init", &name, "--offline"]) {
9461 Ok(status) if status.success() => {}
9462 Ok(status) => {
9463 eprintln!(
9464 "wire {label}: auto-init: `wire init {name}` exited non-zero ({status}) — falling back to default identity"
9465 );
9466 let _ = fs2::FileExt::unlock(&lock_file);
9467 return;
9468 }
9469 Err(e) => {
9470 eprintln!(
9471 "wire {label}: auto-init: failed to spawn `wire init {name}`: {e:#} — falling back to default identity"
9472 );
9473 let _ = fs2::FileExt::unlock(&lock_file);
9474 return;
9475 }
9476 }
9477 try_allocate_local_slot(
9484 &session_home,
9485 &name,
9486 "https://wireup.net",
9487 "http://127.0.0.1:8771",
9488 );
9489 } else {
9490 if std::env::var("WIRE_QUIET_AUTOSESSION").is_err() {
9494 eprintln!(
9495 "wire {label}: auto-init: session `{name}` already exists (concurrent mcp peer won the race) — adopting"
9496 );
9497 }
9498 }
9499 let cwd_key = cwd.to_string_lossy().into_owned();
9509 let name_for_reg = name.clone();
9510 if let Err(e) = crate::session::update_registry(|reg| {
9511 reg.by_cwd.insert(cwd_key, name_for_reg);
9512 Ok(())
9513 }) {
9514 eprintln!("wire {label}: auto-init: failed to update registry: {e:#}");
9515 }
9517 let _ = fs2::FileExt::unlock(&lock_file);
9520
9521 if std::env::var("WIRE_QUIET_AUTOSESSION").is_err() {
9522 eprintln!(
9523 "wire {label}: auto-init: created session `{name}` for cwd `{}` → WIRE_HOME=`{}`",
9524 cwd.display(),
9525 session_home.display()
9526 );
9527 }
9528 unsafe {
9531 std::env::set_var("WIRE_HOME", &session_home);
9532 }
9533}
9534
9535fn ensure_session_daemon(session_home: &std::path::Path) -> Result<()> {
9536 let pidfile = session_home.join("state").join("wire").join("daemon.pid");
9539 if pidfile.exists() {
9540 let bytes = std::fs::read(&pidfile).unwrap_or_default();
9541 let pid: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
9542 v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
9543 } else {
9544 String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
9545 };
9546 if let Some(p) = pid {
9547 let alive = {
9548 #[cfg(target_os = "linux")]
9549 {
9550 std::path::Path::new(&format!("/proc/{p}")).exists()
9551 }
9552 #[cfg(not(target_os = "linux"))]
9553 {
9554 std::process::Command::new("kill")
9555 .args(["-0", &p.to_string()])
9556 .output()
9557 .map(|o| o.status.success())
9558 .unwrap_or(false)
9559 }
9560 };
9561 if alive {
9562 return Ok(());
9563 }
9564 }
9565 }
9566
9567 let bin = std::env::current_exe().with_context(|| "locating self exe")?;
9570 let log_path = session_home.join("state").join("wire").join("daemon.log");
9571 if let Some(parent) = log_path.parent() {
9572 std::fs::create_dir_all(parent).ok();
9573 }
9574 let log_file = std::fs::OpenOptions::new()
9575 .create(true)
9576 .append(true)
9577 .open(&log_path)
9578 .with_context(|| format!("opening daemon log {log_path:?}"))?;
9579 let log_err = log_file.try_clone()?;
9580 std::process::Command::new(&bin)
9581 .env("WIRE_HOME", session_home)
9582 .env_remove("RUST_LOG")
9583 .args(["daemon", "--interval", "5"])
9584 .stdout(log_file)
9585 .stderr(log_err)
9586 .stdin(std::process::Stdio::null())
9587 .spawn()
9588 .with_context(|| "spawning session-local `wire daemon`")?;
9589 Ok(())
9590}
9591
9592fn cmd_session_list(as_json: bool) -> Result<()> {
9593 let items = crate::session::list_sessions()?;
9594 if as_json {
9595 println!("{}", serde_json::to_string(&items)?);
9596 return Ok(());
9597 }
9598 if items.is_empty() {
9599 println!("no sessions on this machine. `wire session new` to create one.");
9600 return Ok(());
9601 }
9602 println!(
9603 "{:<22} {:<24} {:<24} {:<10} CWD",
9604 "PERSONA", "NAME", "HANDLE", "DAEMON"
9605 );
9606 for s in items {
9607 let plain = s
9611 .character
9612 .as_ref()
9613 .map(|c| c.short())
9614 .unwrap_or_else(|| "?".to_string());
9615 let colored = s
9616 .character
9617 .as_ref()
9618 .map(|c| c.colored())
9619 .unwrap_or_else(|| "?".to_string());
9620 let displayed_width = plain.chars().count() + 1; let pad = 22usize.saturating_sub(displayed_width);
9625 println!(
9626 "{}{} {:<24} {:<24} {:<10} {}",
9627 colored,
9628 " ".repeat(pad),
9629 s.name,
9630 s.handle.as_deref().unwrap_or("?"),
9631 if s.daemon_running { "running" } else { "down" },
9632 s.cwd.as_deref().unwrap_or("(no cwd registered)"),
9633 );
9634 }
9635 Ok(())
9636}
9637
9638fn cmd_session_list_local(as_json: bool) -> Result<()> {
9650 let listing = crate::session::list_local_sessions()?;
9651 if as_json {
9652 println!("{}", serde_json::to_string(&listing)?);
9653 return Ok(());
9654 }
9655
9656 if listing.local.is_empty() && listing.federation_only.is_empty() {
9657 println!(
9658 "no sessions on this machine. `wire session new --with-local` to create one \
9659 with a local-relay endpoint (start the relay first: \
9660 `wire relay-server --bind 127.0.0.1:8771 --local-only`)."
9661 );
9662 return Ok(());
9663 }
9664
9665 if listing.local.is_empty() {
9666 println!(
9667 "no sister sessions reachable via a local relay. \
9668 Re-run `wire session new --with-local` to add a Local endpoint, or \
9669 start a local relay with `wire relay-server --bind 127.0.0.1:8771 --local-only`."
9670 );
9671 } else {
9672 let mut keys: Vec<&String> = listing.local.keys().collect();
9674 keys.sort();
9675 for relay_url in keys {
9676 let group = &listing.local[relay_url];
9677 println!("LOCAL RELAY: {relay_url}");
9678 println!(" {:<24} {:<32} {:<10} CWD", "NAME", "HANDLE", "DAEMON");
9679 for s in group {
9680 println!(
9681 " {:<24} {:<32} {:<10} {}",
9682 s.name,
9683 s.handle.as_deref().unwrap_or("?"),
9684 if s.daemon_running { "running" } else { "down" },
9685 s.cwd.as_deref().unwrap_or("(no cwd registered)"),
9686 );
9687 }
9688 println!();
9689 }
9690 }
9691
9692 if !listing.federation_only.is_empty() {
9693 println!("federation-only (no local endpoint):");
9694 for s in &listing.federation_only {
9695 println!(
9696 " {:<24} {:<32} {}",
9697 s.name,
9698 s.handle.as_deref().unwrap_or("?"),
9699 s.cwd.as_deref().unwrap_or("(no cwd registered)"),
9700 );
9701 }
9702 }
9703 Ok(())
9704}
9705
9706fn cmd_session_pair_all_local(
9725 settle_secs: u64,
9726 federation_relay: &str,
9727 as_json: bool,
9728) -> Result<()> {
9729 use std::collections::BTreeSet;
9730 use std::time::Duration;
9731
9732 let listing = crate::session::list_local_sessions()?;
9733 let mut by_name: std::collections::BTreeMap<String, crate::session::LocalSessionView> =
9737 Default::default();
9738 for group in listing.local.into_values() {
9739 for s in group {
9740 by_name.entry(s.name.clone()).or_insert(s);
9741 }
9742 }
9743 let sessions: Vec<crate::session::LocalSessionView> = by_name.into_values().collect();
9744
9745 if sessions.len() < 2 {
9746 let msg = format!(
9747 "{} sister session(s) with a local endpoint — need at least 2 to pair.",
9748 sessions.len()
9749 );
9750 if as_json {
9751 println!(
9752 "{}",
9753 serde_json::to_string(&json!({
9754 "sessions": sessions.iter().map(|s| &s.name).collect::<Vec<_>>(),
9755 "pairs_attempted": 0,
9756 "pairs_succeeded": 0,
9757 "pairs_skipped_already_paired": 0,
9758 "pairs_failed": 0,
9759 "note": msg,
9760 }))?
9761 );
9762 } else {
9763 println!("{msg}");
9764 if let Some(s) = sessions.first() {
9765 println!(" - {} ({})", s.name, s.cwd.as_deref().unwrap_or("?"));
9766 }
9767 println!("Use `wire session new --with-local` to add more.");
9768 }
9769 return Ok(());
9770 }
9771
9772 let fed_host = host_of_url(federation_relay);
9773 if fed_host.is_empty() {
9774 bail!(
9775 "federation_relay `{federation_relay}` has no parseable host — \
9776 pass a full URL like `https://wireup.net`."
9777 );
9778 }
9779
9780 let mut attempted = 0u32;
9782 let mut succeeded = 0u32;
9783 let mut skipped_already = 0u32;
9784 let mut failed = 0u32;
9785 let mut per_pair: Vec<Value> = Vec::new();
9786
9787 for i in 0..sessions.len() {
9788 for j in (i + 1)..sessions.len() {
9789 let a = &sessions[i];
9790 let b = &sessions[j];
9791 attempted += 1;
9792
9793 let a_handle = a.handle.as_deref().unwrap_or(a.name.as_str());
9799 let b_handle = b.handle.as_deref().unwrap_or(b.name.as_str());
9800 let a_pinned_b = session_has_peer(&a.home_dir, b_handle);
9801 let b_pinned_a = session_has_peer(&b.home_dir, a_handle);
9802 if a_pinned_b && b_pinned_a {
9803 skipped_already += 1;
9804 per_pair.push(json!({
9805 "from": a.name,
9806 "to": b.name,
9807 "status": "already_paired",
9808 }));
9809 continue;
9810 }
9811
9812 let pair_result = drive_bilateral_pair(
9813 &a.home_dir,
9814 &a.name,
9815 &b.home_dir,
9816 &b.name,
9817 &fed_host,
9818 federation_relay,
9819 settle_secs,
9820 );
9821
9822 match pair_result {
9823 Ok(()) => {
9824 succeeded += 1;
9825 per_pair.push(json!({
9826 "from": a.name,
9827 "to": b.name,
9828 "status": "paired",
9829 }));
9830 }
9831 Err(e) => {
9832 failed += 1;
9833 let detail = format!("{e:#}");
9834 per_pair.push(json!({
9835 "from": a.name,
9836 "to": b.name,
9837 "status": "failed",
9838 "error": detail,
9839 }));
9840 }
9841 }
9842
9843 std::thread::sleep(Duration::from_millis(200));
9846 }
9847 }
9848
9849 let _ = BTreeSet::<String>::new(); let summary = json!({
9851 "sessions": sessions.iter().map(|s| s.name.clone()).collect::<Vec<_>>(),
9852 "pairs_attempted": attempted,
9853 "pairs_succeeded": succeeded,
9854 "pairs_skipped_already_paired": skipped_already,
9855 "pairs_failed": failed,
9856 "results": per_pair,
9857 });
9858 if as_json {
9859 println!("{}", serde_json::to_string(&summary)?);
9860 } else {
9861 println!(
9862 "wire session pair-all-local: {} session(s), {} pair(s) attempted",
9863 sessions.len(),
9864 attempted
9865 );
9866 println!(" paired: {succeeded}");
9867 println!(" skipped (already pinned): {skipped_already}");
9868 println!(" failed: {failed}");
9869 for entry in summary["results"].as_array().unwrap_or(&vec![]) {
9870 let from = entry["from"].as_str().unwrap_or("?");
9871 let to = entry["to"].as_str().unwrap_or("?");
9872 let status = entry["status"].as_str().unwrap_or("?");
9873 let err = entry.get("error").and_then(Value::as_str).unwrap_or("");
9874 if err.is_empty() {
9875 println!(" {from:<24} ↔ {to:<24} {status}");
9876 } else {
9877 println!(" {from:<24} ↔ {to:<24} {status} — {err}");
9878 }
9879 }
9880 }
9881 Ok(())
9882}
9883
9884fn session_has_peer(session_home: &std::path::Path, peer_name: &str) -> bool {
9887 val_session_relay_state(session_home)
9888 .and_then(|v| v.get("peers").cloned())
9889 .and_then(|p| p.get(peer_name).cloned())
9890 .is_some()
9891}
9892
9893fn val_session_relay_state(session_home: &std::path::Path) -> Option<Value> {
9898 let path = session_home.join("config").join("wire").join("relay.json");
9899 let bytes = std::fs::read(&path).ok()?;
9900 serde_json::from_slice(&bytes).ok()
9901}
9902
9903fn cmd_session_mesh_status(stale_secs: u64, as_json: bool) -> Result<()> {
9907 use std::collections::BTreeMap;
9908
9909 let listing = crate::session::list_local_sessions()?;
9912 let mut by_name: BTreeMap<String, crate::session::LocalSessionView> = BTreeMap::new();
9913 for group in listing.local.into_values() {
9914 for s in group {
9915 by_name.entry(s.name.clone()).or_insert(s);
9916 }
9917 }
9918 let sessions: Vec<crate::session::LocalSessionView> = by_name.into_values().collect();
9919 let federation_only = listing.federation_only;
9920
9921 if sessions.is_empty() {
9922 let msg = "no sister sessions with a local endpoint on this machine.".to_string();
9923 if as_json {
9924 println!(
9925 "{}",
9926 serde_json::to_string(&json!({
9927 "sessions": [],
9928 "edges": [],
9929 "local_relay": null,
9930 "federation_only": federation_only.iter().map(|f| &f.name).collect::<Vec<_>>(),
9931 "summary": {
9932 "session_count": 0,
9933 "edge_count": 0,
9934 "healthy": 0,
9935 "stale": 0,
9936 "asymmetric": 0,
9937 },
9938 "note": msg,
9939 }))?
9940 );
9941 } else {
9942 println!("{msg}");
9943 println!("Use `wire session new --with-local` to create one.");
9944 }
9945 return Ok(());
9946 }
9947
9948 struct SessionState {
9950 view: crate::session::LocalSessionView,
9951 relay_state: Value,
9952 local_relay_url: Option<String>,
9953 }
9954 let mut sstates: Vec<SessionState> = Vec::with_capacity(sessions.len());
9955 for s in sessions {
9956 let relay_state = val_session_relay_state(&s.home_dir)
9957 .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
9958 let local_relay_url = s.local_endpoints.first().map(|e| e.relay_url.clone());
9959 sstates.push(SessionState {
9960 view: s,
9961 relay_state,
9962 local_relay_url,
9963 });
9964 }
9965
9966 let mut local_relays: BTreeMap<String, bool> = BTreeMap::new();
9969 for s in &sstates {
9970 if let Some(url) = &s.local_relay_url
9971 && !local_relays.contains_key(url)
9972 {
9973 let healthy = probe_relay_healthz(url);
9974 local_relays.insert(url.clone(), healthy);
9975 }
9976 }
9977
9978 let now = std::time::SystemTime::now()
9979 .duration_since(std::time::UNIX_EPOCH)
9980 .map(|d| d.as_secs())
9981 .unwrap_or(0);
9982
9983 let mut edges: Vec<Value> = Vec::new();
9987 let mut healthy_count = 0u32;
9988 let mut stale_count = 0u32;
9989 let mut asymmetric_count = 0u32;
9990
9991 for i in 0..sstates.len() {
9992 for j in (i + 1)..sstates.len() {
9993 let a = &sstates[i];
9994 let b = &sstates[j];
9995 let b_key = b.view.handle.as_deref().unwrap_or(b.view.name.as_str());
10000 let a_key = a.view.handle.as_deref().unwrap_or(a.view.name.as_str());
10001 let a_to_b = probe_directed_edge(&a.relay_state, b_key, now);
10002 let b_to_a = probe_directed_edge(&b.relay_state, a_key, now);
10003
10004 let bilateral = a_to_b.pinned && b_to_a.pinned;
10005 let scope = match (a_to_b.scope.as_deref(), b_to_a.scope.as_deref()) {
10009 (Some("local"), _) | (_, Some("local")) => "local",
10010 (Some("federation"), _) | (_, Some("federation")) => "federation",
10011 _ => "unknown",
10012 };
10013
10014 let mut status = if bilateral { "healthy" } else { "asymmetric" };
10017 if bilateral {
10018 let either_stale = [&a_to_b, &b_to_a].iter().any(|d| match d.silent_secs {
10019 Some(s) => s > stale_secs,
10020 None => d.probed,
10021 });
10022 if either_stale {
10023 status = "stale";
10024 }
10025 }
10026
10027 match status {
10028 "healthy" => healthy_count += 1,
10029 "stale" => stale_count += 1,
10030 "asymmetric" => asymmetric_count += 1,
10031 _ => {}
10032 }
10033
10034 edges.push(json!({
10035 "from": a.view.name,
10036 "to": b.view.name,
10037 "bilateral": bilateral,
10038 "scope": scope,
10039 "status": status,
10040 "directions": {
10041 a.view.name.clone(): direction_summary(&a_to_b),
10042 b.view.name.clone(): direction_summary(&b_to_a),
10043 },
10044 }));
10045 }
10046 }
10047
10048 let summary = json!({
10049 "sessions": sstates.iter().map(|s| json!({
10050 "name": s.view.name,
10051 "handle": s.view.handle,
10052 "cwd": s.view.cwd,
10053 "daemon_running": s.view.daemon_running,
10054 "local_relay": s.local_relay_url,
10055 })).collect::<Vec<_>>(),
10056 "edges": edges,
10057 "local_relays": local_relays.iter().map(|(url, healthy)| json!({
10058 "url": url,
10059 "healthy": healthy,
10060 })).collect::<Vec<_>>(),
10061 "federation_only": federation_only.iter().map(|f| &f.name).collect::<Vec<_>>(),
10062 "summary": {
10063 "session_count": sstates.len(),
10064 "edge_count": edges.len(),
10065 "healthy": healthy_count,
10066 "stale": stale_count,
10067 "asymmetric": asymmetric_count,
10068 "stale_threshold_secs": stale_secs,
10069 },
10070 });
10071
10072 if as_json {
10073 println!("{}", serde_json::to_string(&summary)?);
10074 return Ok(());
10075 }
10076
10077 println!(
10078 "wire mesh: {} session(s), {} edge(s)",
10079 sstates.len(),
10080 edges.len()
10081 );
10082 for (url, healthy) in &local_relays {
10083 let tick = if *healthy { "✓" } else { "✗" };
10084 println!(" local-relay {url} {tick}");
10085 }
10086 if !federation_only.is_empty() {
10087 print!(" federation-only sessions:");
10088 for f in &federation_only {
10089 print!(" {}", f.name);
10090 }
10091 println!();
10092 }
10093
10094 let names: Vec<&str> = sstates.iter().map(|s| s.view.name.as_str()).collect();
10096 let col_w = names.iter().map(|n| n.len()).max().unwrap_or(8).max(7) + 1;
10097 print!("\n{:>col_w$}", "", col_w = col_w);
10098 for n in &names {
10099 print!("{:>col_w$}", n, col_w = col_w);
10100 }
10101 println!();
10102 for (i, row) in names.iter().enumerate() {
10103 print!("{:>col_w$}", row, col_w = col_w);
10104 for (j, col) in names.iter().enumerate() {
10105 let cell = if i == j {
10106 "self".to_string()
10107 } else {
10108 let d = probe_directed_edge(&sstates[i].relay_state, col, now);
10109 match d.scope.as_deref() {
10110 Some("local") => "local".to_string(),
10111 Some("federation") => "fed".to_string(),
10112 _ => "—".to_string(),
10113 }
10114 };
10115 print!("{:>col_w$}", cell, col_w = col_w);
10116 }
10117 println!();
10118 }
10119
10120 println!("\nHealth (stale threshold: {stale_secs}s):");
10121 for e in &edges {
10122 let from = e["from"].as_str().unwrap_or("?");
10123 let to = e["to"].as_str().unwrap_or("?");
10124 let scope = e["scope"].as_str().unwrap_or("?");
10125 let status = e["status"].as_str().unwrap_or("?");
10126 let mark = match status {
10127 "healthy" => "✓",
10128 "stale" => "⚠",
10129 "asymmetric" => "!",
10130 _ => "?",
10131 };
10132 let dirs = e["directions"].as_object().cloned().unwrap_or_default();
10133 let mut details: Vec<String> = Vec::new();
10134 for (who, d) in &dirs {
10135 let silent = d.get("silent_secs").and_then(Value::as_u64);
10136 let pinned = d.get("pinned").and_then(Value::as_bool).unwrap_or(false);
10137 let probed = d.get("probed").and_then(Value::as_bool).unwrap_or(false);
10138 let label = match (pinned, probed, silent) {
10139 (false, _, _) => format!("{who} has not pinned"),
10140 (true, false, _) => format!("{who} pinned but no endpoint to probe"),
10141 (true, true, Some(s)) if s <= stale_secs => format!("{who} fresh ({s}s)"),
10142 (true, true, Some(s)) => format!("{who} silent {s}s"),
10143 (true, true, None) => format!("{who} never pulled"),
10144 };
10145 details.push(label);
10146 }
10147 println!(
10148 " {mark} {from} ↔ {to} scope={scope} {status:>10} [{}]",
10149 details.join(" | ")
10150 );
10151 }
10152 Ok(())
10153}
10154
10155#[derive(Default)]
10156struct DirectedEdge {
10157 pinned: bool,
10158 scope: Option<String>,
10159 last_pull_at_unix: Option<u64>,
10160 silent_secs: Option<u64>,
10161 probed: bool,
10162 event_count: usize,
10163}
10164
10165fn probe_directed_edge(from_state: &Value, to_name: &str, now: u64) -> DirectedEdge {
10171 let pinned = from_state
10172 .get("peers")
10173 .and_then(|p| p.get(to_name))
10174 .is_some();
10175 if !pinned {
10176 return DirectedEdge::default();
10177 }
10178 let endpoints = crate::endpoints::peer_endpoints_in_priority_order(from_state, to_name);
10179 let ep = match endpoints.into_iter().next() {
10180 Some(e) => e,
10181 None => {
10182 return DirectedEdge {
10183 pinned: true,
10184 ..Default::default()
10185 };
10186 }
10187 };
10188 let scope = Some(
10189 match ep.scope {
10190 crate::endpoints::EndpointScope::Local => "local",
10191 crate::endpoints::EndpointScope::Lan => "lan",
10192 crate::endpoints::EndpointScope::Uds => "uds",
10193 crate::endpoints::EndpointScope::Federation => "federation",
10194 }
10195 .to_string(),
10196 );
10197 let client = crate::relay_client::RelayClient::new(&ep.relay_url);
10198 let (count, last) = client
10199 .slot_state(&ep.slot_id, &ep.slot_token)
10200 .unwrap_or((0, None));
10201 let silent = last.map(|t| now.saturating_sub(t));
10202 DirectedEdge {
10203 pinned: true,
10204 scope,
10205 last_pull_at_unix: last,
10206 silent_secs: silent,
10207 probed: true,
10208 event_count: count,
10209 }
10210}
10211
10212fn direction_summary(d: &DirectedEdge) -> Value {
10213 json!({
10214 "pinned": d.pinned,
10215 "scope": d.scope,
10216 "probed": d.probed,
10217 "last_pull_at_unix": d.last_pull_at_unix,
10218 "silent_secs": d.silent_secs,
10219 "event_count": d.event_count,
10220 })
10221}
10222
10223fn probe_relay_healthz(url: &str) -> bool {
10225 let probe_url = format!("{}/healthz", url.trim_end_matches('/'));
10226 let client = match reqwest::blocking::Client::builder()
10227 .timeout(std::time::Duration::from_millis(500))
10228 .build()
10229 {
10230 Ok(c) => c,
10231 Err(_) => return false,
10232 };
10233 match client.get(&probe_url).send() {
10234 Ok(r) => r.status().is_success(),
10235 Err(_) => false,
10236 }
10237}
10238
10239fn drive_bilateral_pair(
10254 a_home: &std::path::Path,
10255 a_name: &str,
10256 b_home: &std::path::Path,
10257 b_name: &str,
10258 _fed_host: &str,
10259 _federation_relay: &str,
10260 settle_secs: u64,
10261) -> Result<()> {
10262 use std::time::Duration;
10263 let bin = std::env::current_exe().context("locating self exe")?;
10264
10265 let run = |home: &std::path::Path, args: &[&str]| -> Result<()> {
10266 let out = std::process::Command::new(&bin)
10267 .env("WIRE_HOME", home)
10268 .env_remove("RUST_LOG")
10269 .args(args)
10270 .output()
10271 .with_context(|| format!("spawning `wire {}`", args.join(" ")))?;
10272 if !out.status.success() {
10273 bail!(
10274 "`wire {}` failed: stderr={}",
10275 args.join(" "),
10276 String::from_utf8_lossy(&out.stderr).trim()
10277 );
10278 }
10279 Ok(())
10280 };
10281
10282 let read_card_handle = |home: &std::path::Path| -> Result<String> {
10287 let card_path = home.join("config").join("wire").join("agent-card.json");
10288 let bytes = std::fs::read(&card_path)
10289 .with_context(|| format!("reading agent-card at {card_path:?}"))?;
10290 let card: Value = serde_json::from_slice(&bytes)?;
10291 card.get("handle")
10292 .and_then(Value::as_str)
10293 .map(str::to_string)
10294 .ok_or_else(|| anyhow!("agent-card at {card_path:?} missing `handle` field"))
10295 };
10296 let a_handle = read_card_handle(a_home)
10297 .with_context(|| format!("session {a_name} (a): read agent-card.handle"))?;
10298 let b_handle = read_card_handle(b_home)
10299 .with_context(|| format!("session {b_name} (b): read agent-card.handle"))?;
10300
10301 run(a_home, &["add", b_name, "--local-sister", "--json"])
10305 .with_context(|| format!("step 1/8: {a_name} `wire add {b_name} --local-sister`"))?;
10306
10307 std::thread::sleep(Duration::from_secs(settle_secs));
10309
10310 run(b_home, &["pull", "--json"]).with_context(|| format!("step 4/8: {b_name} `wire pull`"))?;
10313 run(b_home, &["pair-accept", &a_handle, "--json"]).with_context(|| {
10314 format!("step 5/8: {b_name} `wire pair-accept {a_handle}` (a session={a_name})")
10315 })?;
10316 run(b_home, &["push", "--json"]).with_context(|| format!("step 6/8: {b_name} `wire push`"))?;
10317
10318 std::thread::sleep(Duration::from_secs(settle_secs));
10320
10321 run(a_home, &["pull", "--json"]).with_context(|| format!("step 8/8: {a_name} `wire pull`"))?;
10323 let _ = &b_handle;
10325
10326 Ok(())
10327}
10328
10329fn cmd_session_env(name_arg: Option<&str>, as_json: bool) -> Result<()> {
10330 let name = resolve_session_name(name_arg)?;
10331 let session_home = crate::session::session_dir(&name)?;
10332 if !session_home.exists() {
10333 bail!(
10334 "no session named {name:?} on this machine. `wire session list` to enumerate, \
10335 `wire session new {name}` to create."
10336 );
10337 }
10338 if as_json {
10339 println!(
10340 "{}",
10341 serde_json::to_string(&json!({
10342 "name": name,
10343 "home_dir": session_home.to_string_lossy(),
10344 "export": format!("export WIRE_HOME={}", session_home.to_string_lossy()),
10345 }))?
10346 );
10347 } else {
10348 println!("export WIRE_HOME={}", session_home.to_string_lossy());
10349 }
10350 Ok(())
10351}
10352
10353fn cmd_session_current(as_json: bool) -> Result<()> {
10354 let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
10355 let registry = crate::session::read_registry().unwrap_or_default();
10356 let cwd_key = cwd.to_string_lossy().into_owned();
10357 let name = registry.by_cwd.get(&cwd_key).cloned();
10358 if as_json {
10359 println!(
10360 "{}",
10361 serde_json::to_string(&json!({
10362 "cwd": cwd_key,
10363 "session": name,
10364 }))?
10365 );
10366 } else if let Some(n) = name {
10367 println!("{n}");
10368 } else {
10369 println!("(no session registered for this cwd)");
10370 }
10371 Ok(())
10372}
10373
10374fn cmd_session_destroy(name_arg: &str, force: bool, as_json: bool) -> Result<()> {
10375 let name = crate::session::sanitize_name(name_arg);
10376 let session_home = crate::session::session_dir(&name)?;
10377 if !session_home.exists() {
10378 if as_json {
10379 println!(
10380 "{}",
10381 serde_json::to_string(&json!({
10382 "name": name,
10383 "destroyed": false,
10384 "reason": "no such session",
10385 }))?
10386 );
10387 } else {
10388 println!("no session named {name:?} — nothing to destroy.");
10389 }
10390 return Ok(());
10391 }
10392 if !force {
10393 bail!(
10394 "destroying session {name:?} would delete its keypair + state irrecoverably. \
10395 Pass --force to confirm."
10396 );
10397 }
10398
10399 let pidfile = session_home.join("state").join("wire").join("daemon.pid");
10401 if let Ok(bytes) = std::fs::read(&pidfile) {
10402 let pid: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
10403 v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
10404 } else {
10405 String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
10406 };
10407 if let Some(p) = pid {
10408 let _ = std::process::Command::new("kill")
10409 .args(["-TERM", &p.to_string()])
10410 .output();
10411 }
10412 }
10413
10414 std::fs::remove_dir_all(&session_home)
10415 .with_context(|| format!("removing session dir {session_home:?}"))?;
10416
10417 let mut registry = crate::session::read_registry().unwrap_or_default();
10419 registry.by_cwd.retain(|_, v| v != &name);
10420 crate::session::write_registry(®istry)?;
10421
10422 if as_json {
10423 println!(
10424 "{}",
10425 serde_json::to_string(&json!({
10426 "name": name,
10427 "destroyed": true,
10428 }))?
10429 );
10430 } else {
10431 println!("destroyed session {name:?}.");
10432 }
10433 Ok(())
10434}
10435
10436fn cmd_diag(action: DiagAction) -> Result<()> {
10439 let state = config::state_dir()?;
10440 let knob = state.join("diag.enabled");
10441 let log_path = state.join("diag.jsonl");
10442 match action {
10443 DiagAction::Tail { limit, json } => {
10444 let entries = crate::diag::tail(limit);
10445 if json {
10446 for e in entries {
10447 println!("{}", serde_json::to_string(&e)?);
10448 }
10449 } else if entries.is_empty() {
10450 println!("wire diag: no entries (diag may be disabled — `wire diag enable`)");
10451 } else {
10452 for e in entries {
10453 let ts = e["ts"].as_u64().unwrap_or(0);
10454 let ty = e["type"].as_str().unwrap_or("?");
10455 let pid = e["pid"].as_u64().unwrap_or(0);
10456 let payload = e["payload"].to_string();
10457 println!("[{ts}] pid={pid} {ty} {payload}");
10458 }
10459 }
10460 }
10461 DiagAction::Enable => {
10462 config::ensure_dirs()?;
10463 std::fs::write(&knob, "1")?;
10464 println!("wire diag: enabled at {knob:?}");
10465 }
10466 DiagAction::Disable => {
10467 if knob.exists() {
10468 std::fs::remove_file(&knob)?;
10469 }
10470 println!("wire diag: disabled (env WIRE_DIAG may still flip it on per-process)");
10471 }
10472 DiagAction::Status { json } => {
10473 let enabled = crate::diag::is_enabled();
10474 let size = std::fs::metadata(&log_path).map(|m| m.len()).unwrap_or(0);
10475 if json {
10476 println!(
10477 "{}",
10478 serde_json::to_string(&serde_json::json!({
10479 "enabled": enabled,
10480 "log_path": log_path,
10481 "log_size_bytes": size,
10482 }))?
10483 );
10484 } else {
10485 println!("wire diag status");
10486 println!(" enabled: {enabled}");
10487 println!(" log: {log_path:?}");
10488 println!(" log size: {size} bytes");
10489 }
10490 }
10491 }
10492 Ok(())
10493}
10494
10495fn cmd_service(action: ServiceAction) -> Result<()> {
10498 let kind = |local_relay: bool| {
10499 if local_relay {
10500 crate::service::ServiceKind::LocalRelay
10501 } else {
10502 crate::service::ServiceKind::Daemon
10503 }
10504 };
10505 let (report, as_json) = match action {
10506 ServiceAction::Install { local_relay, json } => {
10507 (crate::service::install_kind(kind(local_relay))?, json)
10508 }
10509 ServiceAction::Uninstall { local_relay, json } => {
10510 (crate::service::uninstall_kind(kind(local_relay))?, json)
10511 }
10512 ServiceAction::Status { local_relay, json } => {
10513 (crate::service::status_kind(kind(local_relay))?, json)
10514 }
10515 };
10516 if as_json {
10517 println!("{}", serde_json::to_string(&report)?);
10518 } else {
10519 println!("wire service {}", report.action);
10520 println!(" platform: {}", report.platform);
10521 println!(" unit: {}", report.unit_path);
10522 println!(" status: {}", report.status);
10523 println!(" detail: {}", report.detail);
10524 }
10525 Ok(())
10526}
10527
10528const CRATE_NAME: &str = "slancha-wire";
10531
10532fn release_asset_triple() -> Option<(&'static str, &'static str)> {
10536 #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
10537 {
10538 return Some(("x86_64-pc-windows-msvc", ".exe"));
10539 }
10540 #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
10541 {
10542 return Some(("aarch64-apple-darwin", ""));
10543 }
10544 #[cfg(all(target_os = "macos", target_arch = "x86_64"))]
10545 {
10546 return Some(("x86_64-apple-darwin", ""));
10547 }
10548 #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
10549 {
10550 return Some(("x86_64-unknown-linux-musl", ""));
10551 }
10552 #[cfg(all(target_os = "linux", target_arch = "aarch64"))]
10553 {
10554 return Some(("aarch64-unknown-linux-musl", ""));
10555 }
10556 #[allow(unreachable_code)]
10557 None
10558}
10559
10560fn fetch_latest_published_version() -> Result<String> {
10562 let url = format!("https://crates.io/api/v1/crates/{CRATE_NAME}");
10563 let client = reqwest::blocking::Client::builder()
10564 .timeout(std::time::Duration::from_secs(20))
10565 .build()?;
10566 let resp = client
10567 .get(&url)
10568 .header(
10570 "User-Agent",
10571 format!("wire/{} (self-update)", env!("CARGO_PKG_VERSION")),
10572 )
10573 .send()?;
10574 if !resp.status().is_success() {
10575 bail!("crates.io returned {} for {CRATE_NAME}", resp.status());
10576 }
10577 let v: Value = resp.json()?;
10578 v.get("crate")
10579 .and_then(|c| {
10580 c.get("max_stable_version")
10581 .or_else(|| c.get("newest_version"))
10582 })
10583 .and_then(Value::as_str)
10584 .map(str::to_string)
10585 .ok_or_else(|| anyhow!("crates.io response missing crate.max_stable_version"))
10586}
10587
10588fn version_is_newer(latest: &str, current: &str) -> bool {
10591 let parse = |s: &str| -> (u64, u64, u64) {
10592 let core = s.split('-').next().unwrap_or(s);
10593 let mut it = core.split('.').map(|p| p.parse::<u64>().unwrap_or(0));
10594 (
10595 it.next().unwrap_or(0),
10596 it.next().unwrap_or(0),
10597 it.next().unwrap_or(0),
10598 )
10599 };
10600 parse(latest) > parse(current)
10601}
10602
10603fn cargo_on_path() -> bool {
10604 std::process::Command::new("cargo")
10605 .arg("--version")
10606 .stdout(std::process::Stdio::null())
10607 .stderr(std::process::Stdio::null())
10608 .status()
10609 .map(|s| s.success())
10610 .unwrap_or(false)
10611}
10612
10613fn self_update_from_release(latest: &str) -> Result<()> {
10616 let (triple, ext) = release_asset_triple().ok_or_else(|| {
10617 anyhow!(
10618 "no prebuilt release binary for this platform — install a Rust toolchain and re-run, \
10619 or `cargo install {CRATE_NAME}`"
10620 )
10621 })?;
10622 let base =
10623 format!("https://github.com/SlanchaAi/wire/releases/download/v{latest}/wire-{triple}{ext}");
10624 let client = reqwest::blocking::Client::builder()
10625 .timeout(std::time::Duration::from_secs(120))
10626 .build()?;
10627 let resp = client
10628 .get(&base)
10629 .header("User-Agent", "wire-self-update")
10630 .send()?;
10631 if !resp.status().is_success() {
10632 bail!("downloading {base} returned {}", resp.status());
10633 }
10634 let bytes = resp.bytes()?;
10635
10636 if let Ok(sha) = client
10638 .get(format!("{base}.sha256"))
10639 .header("User-Agent", "wire-self-update")
10640 .send()
10641 && sha.status().is_success()
10642 {
10643 let expected = sha
10644 .text()?
10645 .split_whitespace()
10646 .next()
10647 .unwrap_or("")
10648 .to_string();
10649 if !expected.is_empty() {
10650 use sha2::{Digest, Sha256};
10651 let mut h = Sha256::new();
10652 h.update(&bytes);
10653 let actual = hex::encode(h.finalize());
10654 if expected != actual {
10655 bail!(
10656 "SHA-256 mismatch — expected {expected}, got {actual} (aborting, binary NOT replaced)"
10657 );
10658 }
10659 }
10660 }
10661
10662 let exe = std::env::current_exe().context("locating current exe")?;
10663 let dir = exe
10664 .parent()
10665 .ok_or_else(|| anyhow!("current exe has no parent dir"))?;
10666 let tmp = dir.join(format!(".wire-update-{}", std::process::id()));
10667 std::fs::write(&tmp, &bytes).with_context(|| format!("writing {tmp:?}"))?;
10668 #[cfg(unix)]
10669 {
10670 use std::os::unix::fs::PermissionsExt;
10671 let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755));
10672 std::fs::rename(&tmp, &exe).with_context(|| format!("replacing {exe:?}"))?;
10675 }
10676 #[cfg(windows)]
10677 {
10678 let old = exe.with_extension("old");
10681 let _ = std::fs::remove_file(&old);
10682 std::fs::rename(&exe, &old)
10683 .with_context(|| format!("renaming running exe {exe:?} aside"))?;
10684 std::fs::rename(&tmp, &exe).with_context(|| format!("installing new exe at {exe:?}"))?;
10685 }
10686 Ok(())
10687}
10688
10689struct UpdateOutcome {
10691 current: String,
10692 latest: String,
10693 available: bool,
10695 installed: bool,
10697 via: Option<&'static str>,
10699}
10700
10701fn self_update_step(install: bool) -> Result<UpdateOutcome> {
10705 let current = env!("CARGO_PKG_VERSION").to_string();
10706 let latest = fetch_latest_published_version().context("checking crates.io for latest wire")?;
10707 let available = version_is_newer(&latest, ¤t);
10708 if !install || !available {
10709 return Ok(UpdateOutcome {
10710 current,
10711 latest,
10712 available,
10713 installed: false,
10714 via: None,
10715 });
10716 }
10717 let via = if cargo_on_path() {
10718 eprintln!(
10719 "wire upgrade: {current} → {latest} — installing via `cargo install {CRATE_NAME}` …"
10720 );
10721 let status = std::process::Command::new("cargo")
10722 .args([
10723 "install",
10724 CRATE_NAME,
10725 "--version",
10726 &latest,
10727 "--force",
10728 "--locked",
10729 ])
10730 .status()
10731 .context("running cargo install")?;
10732 if !status.success() {
10733 bail!("`cargo install {CRATE_NAME}` failed");
10734 }
10735 "cargo install"
10736 } else {
10737 eprintln!(
10738 "wire upgrade: {current} → {latest} — no `cargo` on PATH, downloading the prebuilt release binary …"
10739 );
10740 self_update_from_release(&latest)?;
10741 "prebuilt release binary"
10742 };
10743 Ok(UpdateOutcome {
10744 current,
10745 latest,
10746 available,
10747 installed: true,
10748 via: Some(via),
10749 })
10750}
10751
10752fn upgrade_kill_set(
10773 my_pid: Option<u32>,
10774 found_daemon_pids: &[u32],
10775 owned_session_pids: &std::collections::HashSet<u32>,
10776) -> Vec<u32> {
10777 let mut k: Vec<u32> = Vec::new();
10778 if let Some(p) = my_pid {
10779 k.push(p);
10780 }
10781 for &p in found_daemon_pids {
10782 if !owned_session_pids.contains(&p) && Some(p) != my_pid {
10783 k.push(p); }
10785 }
10786 k.sort_unstable();
10787 k.dedup();
10788 k
10789}
10790
10791#[cfg(test)]
10792mod upgrade_tests {
10793 use super::*;
10794 use std::collections::HashSet;
10795
10796 #[test]
10797 fn upgrade_kill_set_is_session_scoped() {
10798 let owned: HashSet<u32> = [100, 200].into_iter().collect();
10800 let k = upgrade_kill_set(Some(100), &[100, 200, 999], &owned);
10802 assert!(k.contains(&100), "must kill my own daemon (to replace it)");
10803 assert!(k.contains(&999), "must sweep a true orphan");
10804 assert!(!k.contains(&200), "must SPARE a sibling session's daemon");
10805
10806 assert_eq!(
10810 upgrade_kill_set(Some(100), &[], &owned),
10811 vec![100],
10812 "own daemon killed even when the process scan is empty"
10813 );
10814
10815 assert_eq!(upgrade_kill_set(None, &[999], &HashSet::new()), vec![999]);
10817 }
10818}
10819
10820fn cmd_upgrade(check_only: bool, local: bool, as_json: bool) -> Result<()> {
10821 let update: Option<UpdateOutcome> = if local {
10827 None
10828 } else {
10829 match self_update_step(!check_only) {
10830 Ok(o) => Some(o),
10831 Err(e) => {
10832 if !check_only {
10833 eprintln!("wire upgrade: update check skipped — {e:#}");
10834 }
10835 None
10836 }
10837 }
10838 };
10839 if let Some(o) = &update
10840 && o.installed
10841 {
10842 eprintln!(
10843 "wire upgrade: installed {} (was {}, via {}); restarting the daemon on the new binary.",
10844 o.latest,
10845 o.current,
10846 o.via.unwrap_or("self-update")
10847 );
10848 }
10849
10850 let daemon_pids: Vec<u32> = crate::platform::find_processes_by_cmdline("wire daemon");
10859 let relay_pids: Vec<u32> = crate::platform::find_processes_by_cmdline("wire relay-server");
10860 let running_pids: Vec<u32> = daemon_pids
10861 .iter()
10862 .chain(relay_pids.iter())
10863 .copied()
10864 .collect();
10865
10866 let record = crate::ensure_up::read_pid_record("daemon");
10868 let recorded_version: Option<String> = match &record {
10869 crate::ensure_up::PidRecord::Json(d) => Some(d.version.clone()),
10870 crate::ensure_up::PidRecord::LegacyInt(_) => Some("<pre-0.5.11>".to_string()),
10871 _ => None,
10872 };
10873 let cli_version = env!("CARGO_PKG_VERSION").to_string();
10874
10875 let my_daemon_pid = record.pid();
10889 let owned_session_pids: std::collections::HashSet<u32> = crate::session::list_sessions()
10890 .unwrap_or_default()
10891 .iter()
10892 .filter_map(|s| crate::session::session_daemon_pid(&s.home_dir))
10893 .collect();
10894 let kill_set = upgrade_kill_set(my_daemon_pid, &daemon_pids, &owned_session_pids);
10895 if check_only {
10898 let sessions_with_daemons: Vec<String> = crate::session::list_sessions()
10900 .unwrap_or_default()
10901 .iter()
10902 .filter(|s| s.daemon_running)
10903 .map(|s| s.name.clone())
10904 .collect();
10905 let mut path_dupes: Vec<String> = Vec::new();
10906 if let Ok(path) = std::env::var("PATH") {
10907 let mut seen: std::collections::HashSet<std::path::PathBuf> =
10908 std::collections::HashSet::new();
10909 for dir in path.split(':') {
10910 let candidate = std::path::PathBuf::from(dir).join("wire");
10911 if candidate.exists() {
10912 let canon = candidate.canonicalize().unwrap_or(candidate);
10913 if seen.insert(canon.clone()) {
10914 path_dupes.push(canon.to_string_lossy().into_owned());
10915 }
10916 }
10917 }
10918 }
10919 let installed_service_kinds: Vec<&'static str> = [
10922 (crate::service::ServiceKind::Daemon, "daemon"),
10923 (crate::service::ServiceKind::LocalRelay, "local-relay"),
10924 ]
10925 .into_iter()
10926 .filter_map(|(k, label)| {
10927 crate::service::status_kind(k)
10928 .ok()
10929 .filter(|r| r.status != "absent")
10930 .map(|_| label)
10931 })
10932 .collect();
10933 let (update_latest, update_available) = match &update {
10934 Some(o) => (Some(o.latest.clone()), o.available),
10935 None => (None, false),
10936 };
10937 let report = json!({
10938 "running_pids": running_pids,
10939 "running_daemons": daemon_pids,
10940 "running_relay_servers": relay_pids,
10941 "pidfile_version": recorded_version,
10942 "cli_version": cli_version,
10943 "latest_published": update_latest,
10944 "update_available": update_available,
10945 "would_kill": kill_set,
10946 "would_refresh_services": installed_service_kinds,
10947 "session_daemons_running": sessions_with_daemons,
10948 "path_binaries": path_dupes,
10949 "path_duplicate_warning": path_dupes.len() > 1,
10950 });
10951 if as_json {
10952 println!("{}", serde_json::to_string(&report)?);
10953 } else {
10954 println!("wire upgrade --check");
10955 println!(" cli version: {cli_version}");
10956 match (&update_latest, update_available) {
10957 (Some(l), true) => println!(" latest published: {l} (UPDATE AVAILABLE)"),
10958 (Some(l), false) => println!(" latest published: {l} (up to date)"),
10959 (None, _) => println!(" latest published: (crates.io check skipped)"),
10960 }
10961 println!(
10962 " pidfile version: {}",
10963 recorded_version.as_deref().unwrap_or("(missing)")
10964 );
10965 if running_pids.is_empty() {
10966 println!(" running daemons: none");
10967 println!(" running relays: none");
10968 } else {
10969 if daemon_pids.is_empty() {
10970 println!(" running daemons: none");
10971 } else {
10972 let p: Vec<String> = daemon_pids.iter().map(|p| p.to_string()).collect();
10973 println!(" running daemons: pids {}", p.join(", "));
10974 }
10975 if relay_pids.is_empty() {
10976 println!(" running relays: none");
10977 } else {
10978 let p: Vec<String> = relay_pids.iter().map(|p| p.to_string()).collect();
10979 println!(" running relays: pids {}", p.join(", "));
10980 }
10981 println!(" would kill all + spawn fresh");
10982 }
10983 if !installed_service_kinds.is_empty() {
10984 println!(
10985 " would refresh: {} installed service unit(s) → new binary path",
10986 installed_service_kinds.join(", ")
10987 );
10988 }
10989 if !sessions_with_daemons.is_empty() {
10990 println!(
10991 " session daemons: {} (would respawn under new binary)",
10992 sessions_with_daemons.join(", ")
10993 );
10994 }
10995 if path_dupes.len() > 1 {
10996 println!(
10997 " PATH warning: {} distinct `wire` binaries on PATH:",
10998 path_dupes.len()
10999 );
11000 for b in &path_dupes {
11001 println!(" {b}");
11002 }
11003 println!(" operators should remove the stale ones");
11004 }
11005 }
11006 return Ok(());
11007 }
11008
11009 for pid in &kill_set {
11021 let _ = crate::platform::kill_process(*pid, false); }
11023 if !kill_set.is_empty() {
11024 let deadline = std::time::Instant::now() + std::time::Duration::from_millis(800);
11026 while std::time::Instant::now() < deadline && kill_set.iter().any(|p| process_alive_pid(*p))
11027 {
11028 std::thread::sleep(std::time::Duration::from_millis(50));
11029 }
11030 for pid in &kill_set {
11033 if process_alive_pid(*pid) {
11034 let _ = crate::platform::kill_process(*pid, true);
11035 }
11036 }
11037 std::thread::sleep(std::time::Duration::from_millis(200)); }
11039 let killed: Vec<u32> = kill_set
11041 .iter()
11042 .copied()
11043 .filter(|p| !process_alive_pid(*p))
11044 .collect();
11045
11046 let pidfile = config::state_dir()?.join("daemon.pid");
11049 if pidfile.exists() {
11050 let _ = std::fs::remove_file(&pidfile);
11051 }
11052
11053 let mut path_dupes: Vec<String> = Vec::new();
11065 if let Ok(path) = std::env::var("PATH") {
11066 let mut seen: std::collections::HashSet<std::path::PathBuf> =
11067 std::collections::HashSet::new();
11068 for dir in path.split(':') {
11069 let candidate = std::path::PathBuf::from(dir).join("wire");
11070 if candidate.exists() {
11071 let canon = candidate.canonicalize().unwrap_or(candidate);
11072 if seen.insert(canon.clone()) {
11073 path_dupes.push(canon.to_string_lossy().into_owned());
11074 }
11075 }
11076 }
11077 }
11078 let path_warning = if path_dupes.len() > 1 {
11079 Some(format!(
11080 "WARN: {} distinct `wire` binaries on PATH — old versions can shadow the fresh install:\n {}",
11081 path_dupes.len(),
11082 path_dupes.join("\n ")
11083 ))
11084 } else {
11085 None
11086 };
11087
11088 let mut service_refreshes: Vec<Value> = Vec::new();
11102 for kind in [
11103 crate::service::ServiceKind::Daemon,
11104 crate::service::ServiceKind::LocalRelay,
11105 ] {
11106 let already_installed = crate::service::status_kind(kind)
11107 .map(|r| r.status != "absent")
11108 .unwrap_or(false);
11109 if !already_installed {
11110 continue;
11111 }
11112 match crate::service::install_kind(kind) {
11113 Ok(rep) => service_refreshes.push(json!({
11114 "kind": rep.kind,
11115 "platform": rep.platform,
11116 "status": rep.status,
11117 "unit_path": rep.unit_path,
11118 "action": "refreshed",
11119 })),
11120 Err(e) => service_refreshes.push(json!({
11121 "kind": format!("{kind:?}"),
11122 "action": "refresh_failed",
11123 "error": format!("{e:#}"),
11124 })),
11125 }
11126 }
11127
11128 let spawned = crate::ensure_up::ensure_daemon_running()?;
11134
11135 let session_respawns: Vec<Value> = Vec::new();
11140
11141 let new_record = crate::ensure_up::read_pid_record("daemon");
11142 let new_pid = new_record.pid();
11143 let new_version: Option<String> = if let crate::ensure_up::PidRecord::Json(d) = &new_record {
11144 Some(d.version.clone())
11145 } else {
11146 None
11147 };
11148
11149 if as_json {
11150 println!(
11151 "{}",
11152 serde_json::to_string(&json!({
11153 "killed": killed,
11154 "found_daemons": daemon_pids,
11155 "spared_relay_servers": relay_pids,
11156 "service_refreshes": service_refreshes,
11157 "spawned_fresh_daemon": spawned,
11158 "new_pid": new_pid,
11159 "new_version": new_version,
11160 "cli_version": cli_version,
11161 "session_respawns": session_respawns,
11162 "path_binaries": path_dupes,
11163 "path_warning": path_warning,
11164 }))?
11165 );
11166 } else {
11167 if killed.is_empty() {
11168 println!("wire upgrade: no stale wire processes running");
11169 } else {
11170 let killed_list = killed
11171 .iter()
11172 .map(|p| p.to_string())
11173 .collect::<Vec<_>>()
11174 .join(", ");
11175 if relay_pids.is_empty() {
11180 println!(
11181 "wire upgrade: killed {} daemon(s) [{killed_list}]",
11182 killed.len()
11183 );
11184 } else {
11185 let relay_list = relay_pids
11186 .iter()
11187 .map(|p| p.to_string())
11188 .collect::<Vec<_>>()
11189 .join(", ");
11190 println!(
11191 "wire upgrade: killed {} daemon(s) [{killed_list}]; spared {} shared relay-server(s) [{relay_list}]",
11192 killed.len(),
11193 relay_pids.len()
11194 );
11195 }
11196 }
11197 if !service_refreshes.is_empty() {
11198 println!(
11199 "wire upgrade: refreshed {} installed service unit(s) to point at the new binary:",
11200 service_refreshes.len()
11201 );
11202 for r in &service_refreshes {
11203 let kind = r.get("kind").and_then(Value::as_str).unwrap_or("?");
11204 let action = r.get("action").and_then(Value::as_str).unwrap_or("?");
11205 let status = r.get("status").and_then(Value::as_str).unwrap_or("");
11206 let platform = r.get("platform").and_then(Value::as_str).unwrap_or("");
11207 if action == "refreshed" {
11208 println!(" - {kind}: {action} ({status}, {platform})");
11209 } else {
11210 let err = r.get("error").and_then(Value::as_str).unwrap_or("");
11211 println!(" - {kind}: {action} ({err})");
11212 }
11213 }
11214 }
11215 if spawned {
11216 println!(
11217 "wire upgrade: spawned fresh daemon (pid {} v{})",
11218 new_pid
11219 .map(|p| p.to_string())
11220 .unwrap_or_else(|| "?".to_string()),
11221 new_version.as_deref().unwrap_or(&cli_version),
11222 );
11223 } else {
11224 println!("wire upgrade: daemon was already running on current binary");
11225 }
11226 if !session_respawns.is_empty() {
11227 println!(
11228 "wire upgrade: refreshed {} session daemon(s):",
11229 session_respawns.len()
11230 );
11231 for r in &session_respawns {
11232 let h = r["session_home"].as_str().unwrap_or("?");
11233 let s = r["status"].as_str().unwrap_or("?");
11234 let label = std::path::Path::new(h)
11235 .file_name()
11236 .map(|f| f.to_string_lossy().into_owned())
11237 .unwrap_or_else(|| h.to_string());
11238 println!(" {label:<24} {s}");
11239 }
11240 }
11241 if let Some(msg) = &path_warning {
11242 eprintln!("wire upgrade: {msg}");
11243 }
11244 }
11245 Ok(())
11246}
11247
11248fn json_default(explicit: bool) -> bool {
11258 if explicit {
11259 return true;
11260 }
11261 if std::env::var("WIRE_NO_AUTO_JSON").is_ok() {
11262 return false;
11263 }
11264 use std::io::IsTerminal;
11265 !std::io::stdout().is_terminal()
11266}
11267
11268fn process_alive_pid(pid: u32) -> bool {
11269 crate::platform::process_alive(pid)
11274}
11275
11276fn levenshtein_ci(a: &str, b: &str) -> usize {
11282 let a: Vec<char> = a.to_ascii_lowercase().chars().collect();
11283 let b: Vec<char> = b.to_ascii_lowercase().chars().collect();
11284 let (a, b) = if a.len() < b.len() { (a, b) } else { (b, a) };
11285 let (m, n) = (a.len(), b.len());
11286 if m == 0 {
11287 return n;
11288 }
11289 let mut prev: Vec<usize> = (0..=m).collect();
11290 let mut curr = vec![0usize; m + 1];
11291 for j in 1..=n {
11292 curr[0] = j;
11293 for i in 1..=m {
11294 let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
11295 curr[i] = std::cmp::min(
11296 std::cmp::min(curr[i - 1] + 1, prev[i] + 1),
11297 prev[i - 1] + cost,
11298 );
11299 }
11300 std::mem::swap(&mut prev, &mut curr);
11301 }
11302 prev[m]
11303}
11304
11305pub fn closest_candidates(
11309 needle: &str,
11310 pool: &[String],
11311 max_distance: usize,
11312 max_results: usize,
11313) -> Vec<String> {
11314 let mut scored: Vec<(usize, &String)> = pool
11315 .iter()
11316 .map(|c| (levenshtein_ci(needle, c), c))
11317 .filter(|(d, _)| *d <= max_distance)
11318 .collect();
11319 scored.sort_by_key(|(d, _)| *d);
11320 scored
11321 .into_iter()
11322 .take(max_results)
11323 .map(|(_, c)| c.clone())
11324 .collect()
11325}
11326
11327fn known_local_names() -> Vec<String> {
11332 let mut names: Vec<String> = Vec::new();
11333 if let Ok(trust) = config::read_trust() {
11334 if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
11340 for (handle, agent) in agents {
11341 names.push(handle.clone());
11342 if let Some(did) = agent.get("did").and_then(Value::as_str) {
11343 let ch = crate::character::Character::from_did(did);
11344 names.push(ch.nickname);
11345 }
11346 }
11347 }
11348 }
11349 if let Ok(sessions) = crate::session::list_sessions() {
11350 for s in sessions {
11351 names.push(s.name.clone());
11352 if let Some(h) = &s.handle {
11353 names.push(h.clone());
11354 }
11355 if let Some(ch) = &s.character {
11356 names.push(ch.nickname.clone());
11357 }
11358 }
11359 }
11360 names.sort();
11361 names.dedup();
11362 names
11363}
11364
11365fn deprecation_warn(verb: &str, replacement: &str, json_mode: bool) {
11373 if json_mode {
11374 return;
11375 }
11376 let key = format!("WIRE_DEPRECATION_NAGGED_{}", verb.replace('-', "_"));
11384 if std::env::var(&key).is_ok() {
11385 return;
11386 }
11387 unsafe {
11391 std::env::set_var(&key, "1");
11392 }
11393 eprintln!(
11394 "wire {verb}: DEPRECATED in v0.9 — use `wire {replacement}`. \
11395 Will be removed in v1.0 (target 2026-Q3). \
11396 Suppress: set WIRE_DEPRECATION_NAGGED_{}=1.",
11397 verb.replace('-', "_")
11398 );
11399}
11400
11401#[derive(Clone, Debug, serde::Serialize)]
11405pub struct DoctorCheck {
11406 pub id: String,
11409 pub status: String,
11411 pub detail: String,
11413 #[serde(skip_serializing_if = "Option::is_none")]
11415 pub fix: Option<String>,
11416}
11417
11418impl DoctorCheck {
11419 fn pass(id: &str, detail: impl Into<String>) -> Self {
11420 Self {
11421 id: id.into(),
11422 status: "PASS".into(),
11423 detail: detail.into(),
11424 fix: None,
11425 }
11426 }
11427 fn warn(id: &str, detail: impl Into<String>, fix: impl Into<String>) -> Self {
11428 Self {
11429 id: id.into(),
11430 status: "WARN".into(),
11431 detail: detail.into(),
11432 fix: Some(fix.into()),
11433 }
11434 }
11435 fn fail(id: &str, detail: impl Into<String>, fix: impl Into<String>) -> Self {
11436 Self {
11437 id: id.into(),
11438 status: "FAIL".into(),
11439 detail: detail.into(),
11440 fix: Some(fix.into()),
11441 }
11442 }
11443}
11444
11445fn cmd_doctor(as_json: bool, recent_rejections: usize) -> Result<()> {
11450 let checks: Vec<DoctorCheck> = vec![
11451 check_daemon_health(),
11452 check_daemon_pid_consistency(),
11453 check_relay_reachable(),
11454 check_pair_rejections(recent_rejections),
11455 check_cursor_progress(),
11456 ];
11457
11458 let fails = checks.iter().filter(|c| c.status == "FAIL").count();
11459 let warns = checks.iter().filter(|c| c.status == "WARN").count();
11460
11461 if as_json {
11462 println!(
11463 "{}",
11464 serde_json::to_string(&json!({
11465 "checks": checks,
11466 "fail_count": fails,
11467 "warn_count": warns,
11468 "ok": fails == 0,
11469 }))?
11470 );
11471 } else {
11472 println!("wire doctor — {} checks", checks.len());
11473 for c in &checks {
11474 let bullet = match c.status.as_str() {
11475 "PASS" => "✓",
11476 "WARN" => "!",
11477 "FAIL" => "✗",
11478 _ => "?",
11479 };
11480 println!(" {bullet} [{}] {}: {}", c.status, c.id, c.detail);
11481 if let Some(fix) = &c.fix {
11482 println!(" fix: {fix}");
11483 }
11484 }
11485 println!();
11486 if fails == 0 && warns == 0 {
11487 println!("ALL GREEN");
11488 } else {
11489 println!("{fails} FAIL, {warns} WARN");
11490 }
11491 }
11492
11493 if fails > 0 {
11494 std::process::exit(1);
11495 }
11496 Ok(())
11497}
11498
11499fn check_daemon_health() -> DoctorCheck {
11506 let snap = crate::ensure_up::daemon_liveness();
11512 let pgrep_pids = &snap.pgrep_pids;
11513 let pidfile_pid = snap.pidfile_pid;
11514 let pidfile_alive = snap.pidfile_alive;
11515 let orphan_pids = &snap.orphan_pids;
11516
11517 let fmt_pids = |xs: &[u32]| -> String {
11518 xs.iter()
11519 .map(|p| p.to_string())
11520 .collect::<Vec<_>>()
11521 .join(", ")
11522 };
11523
11524 match (pgrep_pids.len(), pidfile_alive, orphan_pids.is_empty()) {
11525 (0, _, _) => DoctorCheck::fail(
11526 "daemon",
11527 "no `wire daemon` process running — nothing pulling inbox or pushing outbox",
11528 "`wire daemon &` to start, or re-run `wire up <handle>@<relay>` to bootstrap",
11529 ),
11530 (1, true, true) => DoctorCheck::pass(
11532 "daemon",
11533 format!(
11534 "one daemon running (pid {}, matches pidfile)",
11535 pgrep_pids[0]
11536 ),
11537 ),
11538 (n, true, false) => DoctorCheck::fail(
11540 "daemon",
11541 format!(
11542 "{n} `wire daemon` processes running (pids: {}); pidfile claims pid {} but pgrep also sees orphan(s): {}. \
11543 The orphans race the relay cursor — they advance past events your current binary can't process. \
11544 (Issue #2 exact class.)",
11545 fmt_pids(pgrep_pids),
11546 pidfile_pid.unwrap(),
11547 fmt_pids(orphan_pids),
11548 ),
11549 "`wire upgrade` kills all orphans and spawns a fresh daemon with a clean pidfile",
11550 ),
11551 (n, false, _) => DoctorCheck::fail(
11553 "daemon",
11554 format!(
11555 "{n} `wire daemon` process(es) running (pids: {}) but pidfile {} — \
11556 every running daemon is an orphan, advancing the cursor without coordinating with the current CLI. \
11557 (Issue #2 exact class: doctor previously PASSed this state while `wire status` said DOWN.)",
11558 fmt_pids(pgrep_pids),
11559 match pidfile_pid {
11560 Some(p) => format!("claims pid {p} which is dead"),
11561 None => "is missing".to_string(),
11562 },
11563 ),
11564 "`wire upgrade` to kill the orphan(s) and spawn a fresh daemon",
11565 ),
11566 (n, true, true) => DoctorCheck::warn(
11568 "daemon",
11569 format!(
11570 "{n} `wire daemon` processes running (pids: {}). Multiple daemons race the relay cursor.",
11571 fmt_pids(pgrep_pids)
11572 ),
11573 "kill all-but-one: `pkill -f \"wire daemon\"; wire daemon &`",
11574 ),
11575 }
11576}
11577
11578fn check_daemon_pid_consistency() -> DoctorCheck {
11590 let snap = crate::ensure_up::daemon_liveness();
11591 match &snap.record {
11592 crate::ensure_up::PidRecord::Missing => DoctorCheck::pass(
11593 "daemon_pid_consistency",
11594 "no daemon.pid yet — fresh box or daemon never started",
11595 ),
11596 crate::ensure_up::PidRecord::Corrupt(reason) => DoctorCheck::warn(
11597 "daemon_pid_consistency",
11598 format!("daemon.pid is corrupt: {reason}"),
11599 "delete state/wire/daemon.pid; next `wire daemon &` will rewrite",
11600 ),
11601 crate::ensure_up::PidRecord::LegacyInt(pid) => {
11602 let pid = *pid;
11605 if !crate::ensure_up::pid_is_alive(pid) {
11606 return DoctorCheck::warn(
11607 "daemon_pid_consistency",
11608 format!(
11609 "daemon.pid (legacy-int) points at pid {pid} which is not running. \
11610 Stale pidfile from a crashed pre-0.5.11 daemon. \
11611 (Issue #2: this surface used to PASS while `wire status` said DOWN.)"
11612 ),
11613 "`wire upgrade` (kills any orphan + spawns a fresh daemon with JSON pidfile)",
11614 );
11615 }
11616 DoctorCheck::warn(
11617 "daemon_pid_consistency",
11618 format!(
11619 "daemon.pid is legacy-int form (pid={pid}, no version/bin_path metadata). \
11620 Daemon was started by a pre-0.5.11 binary."
11621 ),
11622 "run `wire upgrade` to kill the old daemon and start a fresh one with the JSON pidfile",
11623 )
11624 }
11625 crate::ensure_up::PidRecord::Json(d) => {
11626 if !snap.pidfile_alive {
11630 return DoctorCheck::warn(
11631 "daemon_pid_consistency",
11632 format!(
11633 "daemon.pid records pid {pid} (v{version}) but that process is not running — \
11634 pidfile is stale. `wire status` will report DOWN, but pre-v0.5.19 doctor \
11635 silently PASSed this state and ignored any live orphan daemons (#2 root cause).",
11636 pid = d.pid,
11637 version = d.version,
11638 ),
11639 "`wire upgrade` to clean up the stale pidfile + spawn a fresh daemon \
11640 (kills any orphan daemon advancing the cursor without coordination)",
11641 );
11642 }
11643 let mut issues: Vec<String> = Vec::new();
11644 if d.schema != crate::ensure_up::DAEMON_PID_SCHEMA {
11645 issues.push(format!(
11646 "schema={} (expected {})",
11647 d.schema,
11648 crate::ensure_up::DAEMON_PID_SCHEMA
11649 ));
11650 }
11651 let cli_version = env!("CARGO_PKG_VERSION");
11652 if d.version != cli_version {
11653 issues.push(format!("version daemon={} cli={cli_version}", d.version));
11654 }
11655 if !std::path::Path::new(&d.bin_path).exists() {
11656 issues.push(format!("bin_path {} missing on disk", d.bin_path));
11657 }
11658 if let Ok(card) = config::read_agent_card()
11660 && let Some(current_did) = card.get("did").and_then(Value::as_str)
11661 && let Some(recorded_did) = &d.did
11662 && recorded_did != current_did
11663 {
11664 issues.push(format!(
11665 "did daemon={recorded_did} config={current_did} — identity drift"
11666 ));
11667 }
11668 if let Ok(state) = config::read_relay_state()
11669 && let Some(current_relay) = state
11670 .get("self")
11671 .and_then(|s| s.get("relay_url"))
11672 .and_then(Value::as_str)
11673 && let Some(recorded_relay) = &d.relay_url
11674 && recorded_relay != current_relay
11675 {
11676 issues.push(format!(
11677 "relay_url daemon={recorded_relay} config={current_relay} — relay-migration drift"
11678 ));
11679 }
11680 if issues.is_empty() {
11681 DoctorCheck::pass(
11682 "daemon_pid_consistency",
11683 format!(
11684 "daemon v{} bound to {} as {}",
11685 d.version,
11686 d.relay_url.as_deref().unwrap_or("?"),
11687 d.did.as_deref().unwrap_or("?")
11688 ),
11689 )
11690 } else {
11691 DoctorCheck::warn(
11692 "daemon_pid_consistency",
11693 format!("daemon pidfile drift: {}", issues.join("; ")),
11694 "`wire upgrade` to atomically restart daemon with current config".to_string(),
11695 )
11696 }
11697 }
11698 }
11699}
11700
11701fn check_relay_reachable() -> DoctorCheck {
11703 let state = match config::read_relay_state() {
11704 Ok(s) => s,
11705 Err(e) => {
11706 return DoctorCheck::fail(
11707 "relay",
11708 format!("could not read relay state: {e}"),
11709 "run `wire up <handle>@<relay>` to bootstrap",
11710 );
11711 }
11712 };
11713 let url = state
11714 .get("self")
11715 .and_then(|s| s.get("relay_url"))
11716 .and_then(Value::as_str)
11717 .unwrap_or("");
11718 if url.is_empty() {
11719 return DoctorCheck::warn(
11720 "relay",
11721 "no relay bound — wire send/pull will not work",
11722 "run `wire bind-relay <url>` or `wire up <handle>@<relay>`",
11723 );
11724 }
11725 let client = crate::relay_client::RelayClient::new(url);
11726 match client.check_healthz() {
11727 Ok(()) => DoctorCheck::pass("relay", format!("{url} healthz=200")),
11728 Err(e) => DoctorCheck::fail(
11729 "relay",
11730 format!("{url} unreachable: {e}"),
11731 format!("network reachable to {url}? relay running? check `curl {url}/healthz`"),
11732 ),
11733 }
11734}
11735
11736fn check_pair_rejections(recent_n: usize) -> DoctorCheck {
11740 let path = match config::state_dir() {
11741 Ok(d) => d.join("pair-rejected.jsonl"),
11742 Err(e) => {
11743 return DoctorCheck::warn(
11744 "pair_rejections",
11745 format!("could not resolve state dir: {e}"),
11746 "set WIRE_HOME or fix XDG_STATE_HOME",
11747 );
11748 }
11749 };
11750 if !path.exists() {
11751 return DoctorCheck::pass(
11752 "pair_rejections",
11753 "no pair-rejected.jsonl — no recorded pair failures",
11754 );
11755 }
11756 let body = match std::fs::read_to_string(&path) {
11757 Ok(b) => b,
11758 Err(e) => {
11759 return DoctorCheck::warn(
11760 "pair_rejections",
11761 format!("could not read {path:?}: {e}"),
11762 "check file permissions",
11763 );
11764 }
11765 };
11766 let lines: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
11767 if lines.is_empty() {
11768 return DoctorCheck::pass("pair_rejections", "pair-rejected.jsonl present but empty");
11769 }
11770 let total = lines.len();
11771 let recent: Vec<&str> = lines.iter().rev().take(recent_n).rev().copied().collect();
11772 let mut summary: Vec<String> = Vec::new();
11773 for line in &recent {
11774 if let Ok(rec) = serde_json::from_str::<Value>(line) {
11775 let peer = rec.get("peer").and_then(Value::as_str).unwrap_or("?");
11776 let code = rec.get("code").and_then(Value::as_str).unwrap_or("?");
11777 summary.push(format!("{peer}/{code}"));
11778 }
11779 }
11780 DoctorCheck::warn(
11781 "pair_rejections",
11782 format!(
11783 "{total} pair failures recorded. recent: [{}]",
11784 summary.join(", ")
11785 ),
11786 format!(
11787 "inspect {path:?} for full details. Each entry is a pair-flow error that previously silently dropped — re-run `wire pair <handle>@<relay>` to retry."
11788 ),
11789 )
11790}
11791
11792fn check_cursor_progress() -> DoctorCheck {
11797 let state = match config::read_relay_state() {
11798 Ok(s) => s,
11799 Err(e) => {
11800 return DoctorCheck::warn(
11801 "cursor",
11802 format!("could not read relay state: {e}"),
11803 "check ~/Library/Application Support/wire/relay.json",
11804 );
11805 }
11806 };
11807 let cursor = state
11808 .get("self")
11809 .and_then(|s| s.get("last_pulled_event_id"))
11810 .and_then(Value::as_str)
11811 .map(|s| s.chars().take(16).collect::<String>())
11812 .unwrap_or_else(|| "<none>".to_string());
11813 DoctorCheck::pass(
11814 "cursor",
11815 format!(
11816 "current cursor: {cursor}. P0.1 cursor blocking is active — see `wire pull --json` for cursor_blocked / rejected[].blocks_cursor entries."
11817 ),
11818 )
11819}
11820
11821#[cfg(test)]
11822mod doctor_tests {
11823 use super::*;
11824
11825 #[test]
11826 fn doctor_check_constructors_set_status_correctly() {
11827 let p = DoctorCheck::pass("x", "ok");
11832 assert_eq!(p.status, "PASS");
11833 assert_eq!(p.fix, None);
11834
11835 let w = DoctorCheck::warn("x", "watch out", "do this");
11836 assert_eq!(w.status, "WARN");
11837 assert_eq!(w.fix, Some("do this".to_string()));
11838
11839 let f = DoctorCheck::fail("x", "broken", "fix it");
11840 assert_eq!(f.status, "FAIL");
11841 assert_eq!(f.fix, Some("fix it".to_string()));
11842 }
11843
11844 #[test]
11845 fn check_pair_rejections_no_file_is_pass() {
11846 config::test_support::with_temp_home(|| {
11849 config::ensure_dirs().unwrap();
11850 let c = check_pair_rejections(5);
11851 assert_eq!(c.status, "PASS", "no file should be PASS, got {c:?}");
11852 });
11853 }
11854
11855 #[test]
11856 fn check_pair_rejections_with_entries_warns() {
11857 config::test_support::with_temp_home(|| {
11861 config::ensure_dirs().unwrap();
11862 crate::pair_invite::record_pair_rejection(
11863 "willard",
11864 "pair_drop_ack_send_failed",
11865 "POST 502",
11866 );
11867 let c = check_pair_rejections(5);
11868 assert_eq!(c.status, "WARN");
11869 assert!(c.detail.contains("1 pair failures"));
11870 assert!(c.detail.contains("willard/pair_drop_ack_send_failed"));
11871 });
11872 }
11873}
11874
11875fn cmd_up(
11887 relay_arg: Option<&str>,
11888 name: Option<&str>,
11889 with_local: Option<&str>,
11890 no_local: bool,
11891 as_json: bool,
11892) -> Result<()> {
11893 let relay_url = match relay_arg {
11897 Some(r) => {
11898 let r = r.trim_start_matches('@');
11899 if r.starts_with("http://") || r.starts_with("https://") {
11900 r.to_string()
11901 } else {
11902 format!("https://{r}")
11903 }
11904 }
11905 None => crate::pair_invite::DEFAULT_RELAY.to_string(),
11906 };
11907
11908 let mut report: Vec<(String, String)> = Vec::new();
11909 let mut step = |stage: &str, detail: String| {
11910 report.push((stage.to_string(), detail.clone()));
11911 if !as_json {
11912 eprintln!("wire up: {stage} — {detail}");
11913 }
11914 };
11915
11916 if config::is_initialized()? {
11919 step("init", "already initialized".to_string());
11920 } else {
11921 cmd_init(
11922 None,
11923 name,
11924 Some(&relay_url),
11925 false,
11926 false,
11927 )?;
11928 step("init", format!("created identity bound to {relay_url}"));
11929 }
11930
11931 let canonical = {
11933 let card = config::read_agent_card()?;
11934 let did = card.get("did").and_then(Value::as_str).unwrap_or("");
11935 crate::agent_card::display_handle_from_did(did).to_string()
11936 };
11937 step("identity", format!("persona is `{canonical}`"));
11938
11939 let relay_state = config::read_relay_state()?;
11943 let bound_relay = relay_state
11944 .get("self")
11945 .and_then(|s| s.get("relay_url"))
11946 .and_then(Value::as_str)
11947 .unwrap_or("")
11948 .to_string();
11949 if bound_relay.is_empty() {
11950 cmd_bind_relay(
11954 &relay_url, None, false, false, false,
11956 )?;
11957 step("bind-relay", format!("bound to {relay_url}"));
11958 } else if bound_relay != relay_url {
11959 step(
11960 "bind-relay",
11961 format!(
11962 "WARNING: identity bound to {bound_relay} but you specified {relay_url}. \
11963 Keeping existing binding. Run `wire bind-relay {relay_url}` to switch."
11964 ),
11965 );
11966 } else {
11967 step("bind-relay", format!("already bound to {bound_relay}"));
11968 }
11969
11970 match cmd_claim(
11973 &canonical,
11974 Some(&relay_url),
11975 None,
11976 false,
11977 false,
11978 ) {
11979 Ok(()) => step(
11980 "claim",
11981 format!("{canonical}@{} claimed", strip_proto(&relay_url)),
11982 ),
11983 Err(e) => step(
11984 "claim",
11985 format!("WARNING: claim failed: {e}. You can retry `wire claim {canonical}`."),
11986 ),
11987 }
11988
11989 if no_local {
11994 step("local-slot", "skipped (--no-local)".to_string());
11995 } else {
11996 let local_url = with_local
11997 .unwrap_or("http://127.0.0.1:8771")
11998 .trim_end_matches('/');
11999 let already_local = crate::endpoints::self_endpoints(
12000 &config::read_relay_state().unwrap_or_else(|_| json!({})),
12001 )
12002 .iter()
12003 .any(|e| e.relay_url == local_url);
12004 if relay_url.trim_end_matches('/') == local_url || already_local {
12005 step("local-slot", "already covered".to_string());
12006 } else if crate::relay_client::RelayClient::new(local_url)
12007 .check_healthz()
12008 .is_ok()
12009 {
12010 match cmd_bind_relay(
12011 local_url,
12012 Some("local"),
12013 false,
12014 false,
12015 false,
12016 ) {
12017 Ok(()) => step(
12018 "local-slot",
12019 format!("dual-bound local relay {local_url} for sister routing"),
12020 ),
12021 Err(e) => step("local-slot", format!("skipped local relay: {e}")),
12022 }
12023 } else {
12024 step(
12025 "local-slot",
12026 format!(
12027 "no local relay reachable at {local_url} — federation only \
12028 (sisters resolve via session-list)"
12029 ),
12030 );
12031 }
12032 }
12033
12034 match crate::ensure_up::ensure_daemon_running() {
12036 Ok(true) => step("daemon", "started fresh background daemon".to_string()),
12037 Ok(false) => step("daemon", "already running".to_string()),
12038 Err(e) => step(
12039 "daemon",
12040 format!("WARNING: could not start daemon: {e}. Run `wire daemon &` manually."),
12041 ),
12042 }
12043
12044 let summary =
12046 "ready. `wire pair <peer>@<relay>` to pair, `wire send <peer> \"<msg>\"` to send, \
12047 `wire monitor` to watch incoming events."
12048 .to_string();
12049 step("ready", summary.clone());
12050
12051 if as_json {
12052 let steps_json: Vec<_> = report
12053 .iter()
12054 .map(|(k, v)| json!({"stage": k, "detail": v}))
12055 .collect();
12056 println!(
12057 "{}",
12058 serde_json::to_string(&json!({
12059 "nick": canonical,
12060 "relay": relay_url,
12061 "steps": steps_json,
12062 }))?
12063 );
12064 }
12065 Ok(())
12066}
12067
12068fn strip_proto(url: &str) -> String {
12070 url.trim_start_matches("https://")
12071 .trim_start_matches("http://")
12072 .to_string()
12073}
12074
12075fn cmd_pair_megacommand(
12089 handle_arg: &str,
12090 relay_override: Option<&str>,
12091 timeout_secs: u64,
12092 _as_json: bool,
12093) -> Result<()> {
12094 let parsed = crate::pair_profile::parse_handle(handle_arg)?;
12095 let peer_handle = parsed.nick.clone();
12096
12097 eprintln!("wire pair: resolving {handle_arg}...");
12098 cmd_add(
12099 handle_arg,
12100 relay_override,
12101 false,
12102 false,
12103 )?;
12104
12105 eprintln!(
12106 "wire pair: intro delivered. waiting up to {timeout_secs}s for {peer_handle} \
12107 to ack (their daemon must be running + pulling)..."
12108 );
12109
12110 let _ = run_sync_pull();
12114
12115 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
12116 let poll_interval = std::time::Duration::from_millis(500);
12117
12118 loop {
12119 let _ = run_sync_pull();
12121 let relay_state = config::read_relay_state()?;
12122 let peer_entry = relay_state
12123 .get("peers")
12124 .and_then(|p| p.get(&peer_handle))
12125 .cloned();
12126 let token = peer_entry
12127 .as_ref()
12128 .and_then(|e| e.get("slot_token"))
12129 .and_then(Value::as_str)
12130 .unwrap_or("");
12131
12132 if !token.is_empty() {
12133 let trust = config::read_trust()?;
12135 let pinned_in_trust = trust
12136 .get("agents")
12137 .and_then(|a| a.get(&peer_handle))
12138 .is_some();
12139 println!(
12140 "wire pair: paired with {peer_handle}.\n trust: {} bilateral: yes (slot_token recorded)\n next: `wire send {peer_handle} \"<msg>\"`",
12141 if pinned_in_trust {
12142 "VERIFIED"
12143 } else {
12144 "MISSING (bug)"
12145 }
12146 );
12147 return Ok(());
12148 }
12149
12150 if std::time::Instant::now() >= deadline {
12151 bail!(
12158 "wire pair: timed out after {timeout_secs}s. \
12159 peer {peer_handle} never sent pair_drop_ack. \
12160 likely causes: (a) their daemon is down — ask them to run \
12161 `wire status` and `wire daemon &`; (b) their binary is older \
12162 than 0.5.x and doesn't understand pair_drop events — ask \
12163 them to `wire upgrade`; (c) network / relay blip — re-run \
12164 `wire pair {handle_arg}` to retry."
12165 );
12166 }
12167
12168 std::thread::sleep(poll_interval);
12169 }
12170}
12171
12172fn cmd_claim(
12173 nick: &str,
12174 relay_override: Option<&str>,
12175 public_url: Option<&str>,
12176 hidden: bool,
12177 as_json: bool,
12178) -> Result<()> {
12179 let (_did, relay_url, slot_id, slot_token) =
12182 crate::pair_invite::ensure_self_with_relay(relay_override)?;
12183 let card = config::read_agent_card()?;
12184
12185 let did = card.get("did").and_then(Value::as_str).unwrap_or_default();
12194 let canonical = crate::agent_card::display_handle_from_did(did).to_string();
12195 if !canonical.is_empty() && nick != canonical && !as_json {
12196 eprintln!(
12197 "wire claim: typed `{nick}` ignored — one-name rule. Claiming your persona `{canonical}`."
12198 );
12199 }
12200 let nick = if canonical.is_empty() {
12201 nick
12202 } else {
12203 canonical.as_str()
12204 };
12205 if !crate::pair_profile::is_valid_nick(nick) {
12206 bail!(
12207 "phyllis: {nick:?} won't fit in the books — handles need 2-32 chars, lowercase [a-z0-9_-], not on the reserved list"
12208 );
12209 }
12210
12211 let client = crate::relay_client::RelayClient::new(&relay_url);
12212 let discoverable = if hidden { Some(false) } else { None };
12216 let resp =
12217 client.handle_claim_v2(nick, &slot_id, &slot_token, public_url, &card, discoverable)?;
12218
12219 if as_json {
12220 println!(
12221 "{}",
12222 serde_json::to_string(&json!({
12223 "nick": nick,
12224 "relay": relay_url,
12225 "response": resp,
12226 }))?
12227 );
12228 } else {
12229 let domain = public_url
12233 .unwrap_or(&relay_url)
12234 .trim_start_matches("https://")
12235 .trim_start_matches("http://")
12236 .trim_end_matches('/')
12237 .split('/')
12238 .next()
12239 .unwrap_or("<this-relay-domain>")
12240 .to_string();
12241 println!("claimed {nick} on {relay_url} — others can reach you at: {nick}@{domain}");
12242 println!("verify with: wire whois {nick}@{domain}");
12243 }
12244 Ok(())
12245}
12246
12247fn cmd_profile(action: ProfileAction) -> Result<()> {
12248 match action {
12249 ProfileAction::Set { field, value, json } => {
12250 let parsed: Value =
12254 serde_json::from_str(&value).unwrap_or(Value::String(value.clone()));
12255 let new_profile = crate::pair_profile::write_profile_field(&field, parsed)?;
12256 if json {
12257 println!(
12258 "{}",
12259 serde_json::to_string(&json!({
12260 "field": field,
12261 "profile": new_profile,
12262 }))?
12263 );
12264 } else {
12265 println!("profile.{field} set");
12266 }
12267 }
12268 ProfileAction::Get { json } => return cmd_whois(None, json, None),
12269 ProfileAction::Clear { field, json } => {
12270 let new_profile = crate::pair_profile::write_profile_field(&field, Value::Null)?;
12271 if json {
12272 println!(
12273 "{}",
12274 serde_json::to_string(&json!({
12275 "field": field,
12276 "cleared": true,
12277 "profile": new_profile,
12278 }))?
12279 );
12280 } else {
12281 println!("profile.{field} cleared");
12282 }
12283 }
12284 }
12285 Ok(())
12286}
12287
12288fn cmd_setup(apply: bool) -> Result<()> {
12291 use std::path::PathBuf;
12292
12293 let entry = json!({
12303 "command": "wire",
12304 "args": ["mcp"],
12305 "env": {"WIRE_SESSION_ID": "${CLAUDE_CODE_SESSION_ID}"}
12306 });
12307 let entry_pretty = serde_json::to_string_pretty(&json!({"wire": &entry}))?;
12308
12309 let mut targets: Vec<(&str, PathBuf)> = Vec::new();
12312 if let Some(home) = dirs::home_dir() {
12313 targets.push(("Claude Code", home.join(".claude.json")));
12316 targets.push(("Claude Code (alt)", home.join(".config/claude/mcp.json")));
12318 #[cfg(target_os = "macos")]
12320 targets.push((
12321 "Claude Desktop (macOS)",
12322 home.join("Library/Application Support/Claude/claude_desktop_config.json"),
12323 ));
12324 #[cfg(target_os = "windows")]
12326 if let Ok(appdata) = std::env::var("APPDATA") {
12327 targets.push((
12328 "Claude Desktop (Windows)",
12329 PathBuf::from(appdata).join("Claude/claude_desktop_config.json"),
12330 ));
12331 }
12332 targets.push(("Cursor", home.join(".cursor/mcp.json")));
12334 }
12335 targets.push(("project-local (.mcp.json)", PathBuf::from(".mcp.json")));
12337
12338 println!("wire setup\n");
12339 println!("MCP server snippet (add this to your client's mcpServers):");
12340 println!();
12341 println!("{entry_pretty}");
12342 println!();
12343
12344 if !apply {
12345 println!("Probable MCP host config locations on this machine:");
12346 for (name, path) in &targets {
12347 let marker = if path.exists() {
12348 "✓ found"
12349 } else {
12350 " (would create)"
12351 };
12352 println!(" {marker:14} {name}: {}", path.display());
12353 }
12354 println!();
12355 println!("Run `wire setup --apply` to merge wire into each config above.");
12356 println!(
12357 "Existing entries with a different command keep yours unchanged unless wire's exact entry is missing."
12358 );
12359 return Ok(());
12360 }
12361
12362 let mut modified: Vec<String> = Vec::new();
12363 let mut skipped: Vec<String> = Vec::new();
12364 for (name, path) in &targets {
12365 match upsert_mcp_entry(path, "wire", &entry) {
12366 Ok(true) => modified.push(format!("✓ {name} ({})", path.display())),
12367 Ok(false) => skipped.push(format!(" {name} ({}): already configured", path.display())),
12368 Err(e) => skipped.push(format!("✗ {name} ({}): {e}", path.display())),
12369 }
12370 }
12371 if !modified.is_empty() {
12372 println!("Modified:");
12373 for line in &modified {
12374 println!(" {line}");
12375 }
12376 println!();
12377 println!("Restart the app(s) above to load wire MCP.");
12378 }
12379 if !skipped.is_empty() {
12380 println!();
12381 println!("Skipped:");
12382 for line in &skipped {
12383 println!(" {line}");
12384 }
12385 }
12386 Ok(())
12387}
12388
12389fn upsert_mcp_entry(path: &std::path::Path, server_name: &str, entry: &Value) -> Result<bool> {
12392 let mut cfg: Value = if path.exists() {
12393 let body = std::fs::read_to_string(path).context("reading config")?;
12394 serde_json::from_str(&body).unwrap_or_else(|_| json!({}))
12395 } else {
12396 json!({})
12397 };
12398 if !cfg.is_object() {
12399 cfg = json!({});
12400 }
12401 let root = cfg.as_object_mut().unwrap();
12402 let servers = root
12403 .entry("mcpServers".to_string())
12404 .or_insert_with(|| json!({}));
12405 if !servers.is_object() {
12406 *servers = json!({});
12407 }
12408 let map = servers.as_object_mut().unwrap();
12409 if map.get(server_name) == Some(entry) {
12410 return Ok(false);
12411 }
12412 map.insert(server_name.to_string(), entry.clone());
12413 if let Some(parent) = path.parent()
12414 && !parent.as_os_str().is_empty()
12415 {
12416 std::fs::create_dir_all(parent).context("creating parent dir")?;
12417 }
12418 let out = serde_json::to_string_pretty(&cfg)? + "\n";
12419 std::fs::write(path, out).context("writing config")?;
12420 Ok(true)
12421}
12422
12423const STATUSLINE_RENDERER: &str = include_str!("../assets/wire-statusline.sh");
12429
12430fn cmd_setup_statusline(apply: bool, remove: bool) -> Result<()> {
12436 use std::path::PathBuf;
12437 let cfg_dir: PathBuf = std::env::var_os("CLAUDE_CONFIG_DIR")
12438 .map(PathBuf::from)
12439 .or_else(|| dirs::home_dir().map(|h| h.join(".claude")))
12440 .ok_or_else(|| anyhow!("cannot locate Claude config dir (set $CLAUDE_CONFIG_DIR)"))?;
12441 let settings_path = cfg_dir.join("settings.json");
12442 let script_path = cfg_dir.join("wire-statusline.sh");
12443 let (command, command_warn) = statusline_command(&script_path);
12448
12449 println!("wire setup --statusline\n");
12450 println!("Claude config dir: {}", cfg_dir.display());
12451 println!(" renderer: {}", script_path.display());
12452 println!(" settings: {}", settings_path.display());
12453 if let Some(w) = &command_warn {
12454 println!(" ⚠ {w}");
12455 }
12456 println!();
12457
12458 if remove {
12459 if !apply {
12460 println!("Would REMOVE the statusLine key from settings.json and delete the renderer.");
12461 println!("Run `wire setup --statusline --remove --apply` to do it.");
12462 return Ok(());
12463 }
12464 let dropped = remove_statusline_entry(&settings_path)?;
12465 let script_gone = if script_path.exists() {
12466 std::fs::remove_file(&script_path).is_ok()
12467 } else {
12468 false
12469 };
12470 println!(
12471 "Removed: statusLine key {} · renderer {}",
12472 if dropped { "dropped" } else { "absent" },
12473 if script_gone { "deleted" } else { "absent" }
12474 );
12475 return Ok(());
12476 }
12477
12478 if !apply {
12479 println!("Would write the renderer above and merge into settings.json:");
12480 println!();
12481 println!(" \"statusLine\": {{ \"type\": \"command\", \"command\": \"{command}\" }}");
12482 println!();
12483 println!("Resulting statusline: ● <emoji> <nickname> · <cwd>");
12484 println!("Run `wire setup --statusline --apply` to install.");
12485 println!("(Existing settings.json keys are preserved; an invalid settings.json aborts.)");
12486 return Ok(());
12487 }
12488
12489 if let Some(parent) = script_path.parent() {
12490 std::fs::create_dir_all(parent).context("creating Claude config dir")?;
12491 }
12492 std::fs::write(&script_path, STATUSLINE_RENDERER).context("writing renderer script")?;
12493 #[cfg(unix)]
12494 {
12495 use std::os::unix::fs::PermissionsExt;
12496 if let Ok(meta) = std::fs::metadata(&script_path) {
12497 let mut perms = meta.permissions();
12498 perms.set_mode(0o755);
12499 let _ = std::fs::set_permissions(&script_path, perms);
12500 }
12501 }
12502 let changed = upsert_statusline_entry(&settings_path, &command)?;
12503 println!("✓ renderer written: {}", script_path.display());
12504 if changed {
12505 println!("✓ merged statusLine into: {}", settings_path.display());
12506 } else {
12507 println!(
12508 " settings.json already configured: {}",
12509 settings_path.display()
12510 );
12511 }
12512 println!();
12513 println!("Restart Claude Code (or reopen the session) to see your persona in the statusline.");
12514 Ok(())
12515}
12516
12517fn upsert_statusline_entry(path: &std::path::Path, command: &str) -> Result<bool> {
12521 let mut cfg: Value = if path.exists() {
12522 let body = std::fs::read_to_string(path).context("reading settings.json")?;
12523 if body.trim().is_empty() {
12524 json!({})
12525 } else {
12526 serde_json::from_str(&body).context(
12527 "settings.json exists but is not valid JSON — refusing to clobber; fix or remove it first",
12528 )?
12529 }
12530 } else {
12531 json!({})
12532 };
12533 if !cfg.is_object() {
12534 bail!("settings.json root is not a JSON object — refusing to clobber");
12535 }
12536 let desired = json!({"type": "command", "command": command});
12537 let root = cfg.as_object_mut().unwrap();
12538 if root.get("statusLine") == Some(&desired) {
12539 return Ok(false);
12540 }
12541 root.insert("statusLine".to_string(), desired);
12542 if let Some(parent) = path.parent()
12543 && !parent.as_os_str().is_empty()
12544 {
12545 std::fs::create_dir_all(parent).context("creating parent dir")?;
12546 }
12547 let out = serde_json::to_string_pretty(&cfg)? + "\n";
12548 std::fs::write(path, out).context("writing settings.json")?;
12549 Ok(true)
12550}
12551
12552fn remove_statusline_entry(path: &std::path::Path) -> Result<bool> {
12555 if !path.exists() {
12556 return Ok(false);
12557 }
12558 let body = std::fs::read_to_string(path).context("reading settings.json")?;
12559 if body.trim().is_empty() {
12560 return Ok(false);
12561 }
12562 let mut cfg: Value = serde_json::from_str(&body)
12563 .context("settings.json is not valid JSON — refusing to edit")?;
12564 let Some(root) = cfg.as_object_mut() else {
12565 return Ok(false);
12566 };
12567 if root.remove("statusLine").is_none() {
12568 return Ok(false);
12569 }
12570 let out = serde_json::to_string_pretty(&cfg)? + "\n";
12571 std::fs::write(path, out).context("writing settings.json")?;
12572 Ok(true)
12573}
12574
12575fn statusline_command(script_path: &std::path::Path) -> (String, Option<String>) {
12578 #[cfg(windows)]
12579 {
12580 match resolve_git_bash() {
12581 Some(bash) => (format!("\"{}\" \"{}\"", bash, script_path.display()), None),
12582 None => (
12583 format!("bash \"{}\"", script_path.display()),
12584 Some(
12585 "could not locate git-bash; using bare `bash`. On Windows that may resolve to \
12586 WSL (System32\\bash.exe) and the statusline will be blank — install Git for \
12587 Windows or set statusLine.command to your git-bash bash.exe path."
12588 .to_string(),
12589 ),
12590 ),
12591 }
12592 }
12593 #[cfg(unix)]
12594 {
12595 (format!("bash \"{}\"", script_path.display()), None)
12596 }
12597}
12598
12599#[cfg(windows)]
12603fn resolve_git_bash() -> Option<String> {
12604 use std::path::PathBuf;
12605 if let Ok(out) = std::process::Command::new("where.exe").arg("bash").output()
12608 && out.status.success()
12609 {
12610 for line in String::from_utf8_lossy(&out.stdout).lines() {
12611 let p = line.trim();
12612 if !p.is_empty() && !p.to_lowercase().contains("\\system32\\") {
12613 return Some(p.to_string());
12614 }
12615 }
12616 }
12617 let candidates = [
12619 std::env::var("ProgramFiles")
12620 .ok()
12621 .map(|p| format!("{p}\\Git\\bin\\bash.exe")),
12622 std::env::var("ProgramFiles(x86)")
12623 .ok()
12624 .map(|p| format!("{p}\\Git\\bin\\bash.exe")),
12625 std::env::var("LocalAppData")
12626 .ok()
12627 .map(|p| format!("{p}\\Programs\\Git\\bin\\bash.exe")),
12628 ];
12629 candidates
12630 .into_iter()
12631 .flatten()
12632 .find(|c| PathBuf::from(c).exists())
12633}
12634
12635#[cfg(test)]
12636mod statusline_tests {
12637 use super::*;
12638
12639 #[test]
12640 fn statusline_merge_preserves_keys_and_is_idempotent() {
12641 let dir = tempfile::tempdir().unwrap();
12642 let path = dir.path().join("settings.json");
12643 std::fs::write(&path, r#"{"theme":"dark","model":"opus"}"#).unwrap();
12644 assert!(upsert_statusline_entry(&path, "bash /x.sh").unwrap());
12646 let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
12647 assert_eq!(v["theme"], "dark");
12648 assert_eq!(v["model"], "opus");
12649 assert_eq!(v["statusLine"]["type"], "command");
12650 assert_eq!(v["statusLine"]["command"], "bash /x.sh");
12651 assert!(!upsert_statusline_entry(&path, "bash /x.sh").unwrap());
12653 assert!(remove_statusline_entry(&path).unwrap());
12655 let v2: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
12656 assert_eq!(v2["theme"], "dark");
12657 assert!(v2.get("statusLine").is_none());
12658 assert!(!remove_statusline_entry(&path).unwrap());
12660 }
12661
12662 #[test]
12663 fn statusline_merge_refuses_to_clobber_invalid_json() {
12664 let dir = tempfile::tempdir().unwrap();
12665 let path = dir.path().join("settings.json");
12666 std::fs::write(&path, "this is not json {").unwrap();
12667 let err = upsert_statusline_entry(&path, "bash /x.sh").unwrap_err();
12668 assert!(
12669 format!("{err:#}").contains("not valid JSON"),
12670 "err: {err:#}"
12671 );
12672 assert_eq!(
12674 std::fs::read_to_string(&path).unwrap(),
12675 "this is not json {"
12676 );
12677 }
12678
12679 #[test]
12680 fn statusline_creates_settings_when_absent() {
12681 let dir = tempfile::tempdir().unwrap();
12682 let path = dir.path().join("settings.json");
12683 assert!(upsert_statusline_entry(&path, "bash /x.sh").unwrap());
12684 let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
12685 assert_eq!(v["statusLine"]["command"], "bash /x.sh");
12686 }
12687}
12688
12689fn cmd_notify(
12692 interval_secs: u64,
12693 peer_filter: Option<&str>,
12694 once: bool,
12695 as_json: bool,
12696) -> Result<()> {
12697 use crate::inbox_watch::InboxWatcher;
12698 let cursor_path = config::state_dir()?.join("notify.cursor");
12699 let mut watcher = InboxWatcher::from_cursor_file(&cursor_path)?;
12700
12701 let sweep = |watcher: &mut InboxWatcher| -> Result<()> {
12702 let events = watcher.poll()?;
12703 for ev in events {
12704 if let Some(p) = peer_filter
12705 && ev.peer != p
12706 {
12707 continue;
12708 }
12709 if as_json {
12710 println!("{}", serde_json::to_string(&ev)?);
12711 } else {
12712 os_notify_inbox_event(&ev);
12713 }
12714 }
12715 watcher.save_cursors(&cursor_path)?;
12716 Ok(())
12717 };
12718
12719 if once {
12720 return sweep(&mut watcher);
12721 }
12722
12723 let interval = std::time::Duration::from_secs(interval_secs.max(1));
12724 loop {
12725 if let Err(e) = sweep(&mut watcher) {
12726 eprintln!("wire notify: sweep error: {e}");
12727 }
12728 std::thread::sleep(interval);
12729 }
12730}
12731
12732fn os_notify_inbox_event(ev: &crate::inbox_watch::InboxEvent) {
12733 let who = persona_label(&ev.peer);
12734 let title = if ev.verified {
12735 format!("wire ← {who}")
12736 } else {
12737 format!("wire ← {who} (UNVERIFIED)")
12738 };
12739 let body = format!("{}: {}", ev.kind, ev.body_preview);
12740 crate::os_notify::toast(&title, &body);
12741}
12742
12743#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
12744fn os_toast(title: &str, body: &str) {
12745 eprintln!("[wire notify] {title}\n {body}");
12746}
12747
12748