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 {
198 peer: Option<String>,
200 #[arg(long)]
202 json: bool,
203 #[arg(long, default_value_t = 0)]
205 limit: usize,
206 #[arg(long)]
209 oldest: bool,
210 },
211 Monitor {
222 #[arg(long)]
224 peer: Option<String>,
225 #[arg(long)]
227 json: bool,
228 #[arg(long)]
231 include_handshake: bool,
232 #[arg(long, default_value_t = 500)]
234 interval_ms: u64,
235 #[arg(long, default_value_t = 0)]
237 replay: usize,
238 },
239 Verify {
241 path: String,
243 #[arg(long)]
245 json: bool,
246 },
247 Mcp,
251 RelayServer {
253 #[arg(long, default_value = "127.0.0.1:8770")]
255 bind: String,
256 #[arg(long)]
264 local_only: bool,
265 #[arg(long)]
271 uds: Option<std::path::PathBuf>,
272 },
273 BindRelay {
282 url: String,
284 #[arg(long)]
290 scope: Option<String>,
291 #[arg(long)]
297 replace: bool,
298 #[arg(long)]
304 migrate_pinned: bool,
305 #[arg(long)]
306 json: bool,
307 },
308 AddPeerSlot {
311 handle: String,
313 url: String,
315 slot_id: String,
317 slot_token: String,
319 #[arg(long)]
320 json: bool,
321 },
322 Push {
324 peer: Option<String>,
326 #[arg(long)]
327 json: bool,
328 },
329 Pull {
331 #[arg(long)]
332 json: bool,
333 },
334 Status {
337 #[arg(long)]
339 peer: Option<String>,
340 #[arg(long)]
341 json: bool,
342 },
343 Responder {
345 #[command(subcommand)]
346 command: ResponderCommand,
347 },
348 Pin {
351 card_file: String,
353 #[arg(long)]
354 json: bool,
355 },
356 RotateSlot {
367 #[arg(long)]
370 no_announce: bool,
371 #[arg(long)]
372 json: bool,
373 },
374 ForgetPeer {
378 handle: String,
380 #[arg(long)]
382 purge: bool,
383 #[arg(long)]
384 json: bool,
385 },
386 Daemon {
390 #[arg(long, default_value_t = 5)]
392 interval: u64,
393 #[arg(long)]
395 once: bool,
396 #[arg(long)]
397 json: bool,
398 },
399 #[command(hide = true)] PairHost {
405 #[arg(long)]
407 relay: String,
408 #[arg(long)]
412 yes: bool,
413 #[arg(long, default_value_t = 300)]
415 timeout: u64,
416 #[arg(long)]
422 detach: bool,
423 #[arg(long)]
425 json: bool,
426 },
427 #[command(alias = "join")]
431 #[command(hide = true)] PairJoin {
433 code_phrase: String,
435 #[arg(long)]
437 relay: String,
438 #[arg(long)]
439 yes: bool,
440 #[arg(long, default_value_t = 300)]
441 timeout: u64,
442 #[arg(long)]
444 detach: bool,
445 #[arg(long)]
447 json: bool,
448 },
449 #[command(hide = true)] PairConfirm {
454 code_phrase: String,
456 digits: String,
458 #[arg(long)]
460 json: bool,
461 },
462 #[command(hide = true)] PairList {
465 #[arg(long)]
467 json: bool,
468 #[arg(long)]
472 watch: bool,
473 #[arg(long, default_value_t = 1)]
475 watch_interval: u64,
476 },
477 #[command(hide = true)] PairCancel {
480 code_phrase: String,
481 #[arg(long)]
482 json: bool,
483 },
484 #[command(hide = true)] PairWatch {
495 code_phrase: String,
496 #[arg(long, default_value = "sas_ready")]
498 status: String,
499 #[arg(long, default_value_t = 300)]
501 timeout: u64,
502 #[arg(long)]
504 json: bool,
505 },
506 #[command(hide = true)] Pair {
520 handle: String,
523 #[arg(long)]
526 code: Option<String>,
527 #[arg(long, default_value = "https://wireup.net")]
529 relay: String,
530 #[arg(long)]
532 yes: bool,
533 #[arg(long, default_value_t = 300)]
535 timeout: u64,
536 #[arg(long)]
539 no_setup: bool,
540 #[arg(long)]
545 detach: bool,
546 },
547 #[command(hide = true)] PairAbandon {
554 code_phrase: String,
556 #[arg(long, default_value = "https://wireup.net")]
558 relay: String,
559 },
560 #[command(hide = true)] PairAccept {
567 peer: String,
569 #[arg(long)]
571 json: bool,
572 },
573 #[command(hide = true)] PairReject {
581 peer: String,
583 #[arg(long)]
585 json: bool,
586 },
587 #[command(hide = true)] PairListInbound {
594 #[arg(long)]
596 json: bool,
597 },
598 #[command(subcommand)]
608 Session(SessionCommand),
609 Identity {
614 #[command(subcommand)]
615 cmd: IdentityCommand,
616 },
617 #[command(subcommand)]
622 Mesh(MeshCommand),
623 #[command(subcommand)]
627 Group(GroupCommand),
628 #[command(subcommand)]
631 Enroll(EnrollCommand),
632 Setup {
637 #[arg(long)]
639 apply: bool,
640 #[arg(long)]
646 statusline: bool,
647 #[arg(long)]
650 remove: bool,
651 },
652 Whois {
656 handle: Option<String>,
658 #[arg(long)]
659 json: bool,
660 #[arg(long)]
663 relay: Option<String>,
664 },
665 Add {
671 handle: String,
674 #[arg(long)]
676 relay: Option<String>,
677 #[arg(long)]
685 local_sister: bool,
686 #[arg(long)]
687 json: bool,
688 },
689 Up {
702 relay: Option<String>,
706 #[arg(long)]
709 name: Option<String>,
710 #[arg(long)]
715 with_local: Option<String>,
716 #[arg(long)]
718 no_local: bool,
719 #[arg(long)]
720 json: bool,
721 },
722 Doctor {
729 #[arg(long)]
731 json: bool,
732 #[arg(long, default_value_t = 5)]
734 recent_rejections: usize,
735 },
736 #[command(visible_alias = "update")]
748 Upgrade {
749 #[arg(long)]
751 check: bool,
752 #[arg(long)]
755 local: bool,
756 #[arg(long)]
757 json: bool,
758 },
759 Service {
764 #[command(subcommand)]
765 action: ServiceAction,
766 },
767 Diag {
772 #[command(subcommand)]
773 action: DiagAction,
774 },
775 #[command(hide = true)]
787 Claim {
788 nick: String,
790 #[arg(long)]
792 relay: Option<String>,
793 #[arg(long)]
795 public_url: Option<String>,
796 #[arg(long)]
804 hidden: bool,
805 #[arg(long)]
806 json: bool,
807 },
808 Profile {
818 #[command(subcommand)]
819 action: ProfileAction,
820 },
821 #[command(hide = true)] Invite {
826 #[arg(long, default_value = "https://wireup.net")]
828 relay: String,
829 #[arg(long, default_value_t = 86_400)]
831 ttl: u64,
832 #[arg(long, default_value_t = 1)]
835 uses: u32,
836 #[arg(long)]
840 share: bool,
841 #[arg(long)]
843 json: bool,
844 },
845 Accept {
855 target: String,
857 #[arg(long)]
859 json: bool,
860 },
861 #[command(alias = "invite-accept")]
869 AcceptInvite {
870 url: String,
872 #[arg(long)]
874 json: bool,
875 },
876 Reject {
879 peer: String,
881 #[arg(long)]
883 json: bool,
884 },
885 Notify {
890 #[arg(long, default_value_t = 2)]
892 interval: u64,
893 #[arg(long)]
895 peer: Option<String>,
896 #[arg(long)]
898 once: bool,
899 #[arg(long)]
903 json: bool,
904 },
905 Quiet {
912 #[command(subcommand)]
913 action: QuietAction,
914 },
915}
916
917#[derive(Subcommand, Debug)]
918pub enum QuietAction {
919 On,
922 Off,
925 Status {
928 #[arg(long)]
931 json: bool,
932 },
933}
934
935#[derive(Subcommand, Debug)]
936pub enum DiagAction {
937 Tail {
939 #[arg(long, default_value_t = 20)]
940 limit: usize,
941 #[arg(long)]
942 json: bool,
943 },
944 Enable,
947 Disable,
949 Status {
951 #[arg(long)]
952 json: bool,
953 },
954}
955
956#[derive(Subcommand, Debug)]
961pub enum EnrollCommand {
962 Op {
964 #[arg(long, default_value = "operator")]
966 handle: String,
967 #[arg(long)]
968 json: bool,
969 },
970 OrgCreate {
972 #[arg(long)]
974 handle: String,
975 #[arg(long)]
976 json: bool,
977 },
978 OrgAddMember {
982 op_did: String,
984 #[arg(long)]
986 org: String,
987 #[arg(long)]
988 json: bool,
989 },
990 Republish {
998 #[arg(long)]
999 json: bool,
1000 },
1001}
1002
1003#[derive(Subcommand, Debug)]
1004pub enum IdentityCommand {
1005 Show {
1008 #[arg(long)]
1009 json: bool,
1010 },
1011 List {
1016 #[arg(long)]
1017 json: bool,
1018 },
1019 #[command(hide = true)]
1027 Publish {
1028 nick: String,
1030 #[arg(long)]
1033 relay: Option<String>,
1034 #[arg(long, alias = "public")]
1037 public_url: Option<String>,
1038 #[arg(long)]
1042 hidden: bool,
1043 #[arg(long)]
1044 json: bool,
1045 },
1046 Destroy {
1050 name: String,
1052 #[arg(long)]
1054 force: bool,
1055 #[arg(long)]
1056 json: bool,
1057 },
1058 Create {
1070 #[arg(long)]
1073 name: Option<String>,
1074 #[arg(long, conflicts_with = "local")]
1077 anonymous: bool,
1078 #[arg(long)]
1081 local: bool,
1082 #[arg(long)]
1083 json: bool,
1084 },
1085 Persist {
1090 name: String,
1092 #[arg(long = "as", value_name = "NEW_NAME")]
1094 as_name: Option<String>,
1095 #[arg(long)]
1096 json: bool,
1097 },
1098 Demote {
1108 name: String,
1110 #[arg(long)]
1111 json: bool,
1112 },
1113}
1114
1115#[derive(Subcommand, Debug)]
1116pub enum SessionCommand {
1117 New {
1125 name: Option<String>,
1127 #[arg(long, default_value = "https://wireup.net")]
1129 relay: String,
1130 #[arg(long)]
1137 with_local: bool,
1138 #[arg(long, default_value = "http://127.0.0.1:8771")]
1142 local_relay: String,
1143 #[arg(long)]
1150 with_lan: bool,
1151 #[arg(long)]
1155 lan_relay: Option<String>,
1156 #[arg(long)]
1163 with_uds: bool,
1164 #[arg(long)]
1168 uds_socket: Option<std::path::PathBuf>,
1169 #[arg(long)]
1172 no_daemon: bool,
1173 #[arg(long)]
1181 local_only: bool,
1182 #[arg(long)]
1184 json: bool,
1185 },
1186 List {
1189 #[arg(long)]
1190 json: bool,
1191 },
1192 ListLocal {
1198 #[arg(long)]
1199 json: bool,
1200 },
1201 PairAllLocal {
1217 #[arg(long, default_value_t = 1)]
1222 settle_secs: u64,
1223 #[arg(long, default_value = "https://wireup.net")]
1228 federation_relay: String,
1229 #[arg(long)]
1230 json: bool,
1231 },
1232 MeshStatus {
1246 #[arg(long, default_value_t = 300)]
1251 stale_secs: u64,
1252 #[arg(long)]
1253 json: bool,
1254 },
1255 Env {
1259 name: Option<String>,
1261 #[arg(long)]
1262 json: bool,
1263 },
1264 Current {
1268 #[arg(long)]
1269 json: bool,
1270 },
1271 Bind {
1279 name: Option<String>,
1283 #[arg(long)]
1284 json: bool,
1285 },
1286 Destroy {
1290 name: String,
1291 #[arg(long)]
1293 force: bool,
1294 #[arg(long)]
1295 json: bool,
1296 },
1297}
1298
1299#[derive(Subcommand, Debug)]
1305pub enum GroupCommand {
1306 Create {
1308 name: String,
1310 #[arg(long)]
1311 json: bool,
1312 },
1313 Add {
1315 group: String,
1317 peer: String,
1319 #[arg(long)]
1320 json: bool,
1321 },
1322 Send {
1324 group: String,
1326 message: String,
1328 #[arg(long)]
1329 json: bool,
1330 },
1331 Tail {
1333 group: String,
1335 #[arg(long, default_value_t = 20)]
1337 limit: usize,
1338 #[arg(long)]
1339 json: bool,
1340 },
1341 List {
1343 #[arg(long)]
1344 json: bool,
1345 },
1346 Invite {
1351 group: String,
1353 #[arg(long)]
1354 json: bool,
1355 },
1356 Join {
1360 code: String,
1362 #[arg(long)]
1363 json: bool,
1364 },
1365}
1366
1367#[derive(Subcommand, Debug)]
1369pub enum MeshCommand {
1370 Status {
1373 #[arg(long, default_value_t = 300)]
1375 stale_secs: u64,
1376 #[arg(long)]
1377 json: bool,
1378 },
1379 Broadcast {
1398 #[arg(long, default_value = "claim")]
1401 kind: String,
1402 #[arg(long, default_value = "local")]
1404 scope: String,
1405 #[arg(long)]
1407 exclude: Vec<String>,
1408 #[arg(long)]
1412 noreply: bool,
1413 body: String,
1415 #[arg(long)]
1416 json: bool,
1417 },
1418 Role {
1427 #[command(subcommand)]
1428 action: MeshRoleAction,
1429 },
1430 Route {
1446 role: String,
1448 #[arg(long, default_value = "round-robin")]
1450 strategy: String,
1451 #[arg(long)]
1453 exclude: Vec<String>,
1454 #[arg(long, default_value = "claim")]
1457 kind: String,
1458 body: String,
1460 #[arg(long)]
1461 json: bool,
1462 },
1463}
1464
1465#[derive(Subcommand, Debug)]
1467pub enum MeshRoleAction {
1468 Set {
1473 role: String,
1474 #[arg(long)]
1475 json: bool,
1476 },
1477 Get {
1480 peer: Option<String>,
1481 #[arg(long)]
1482 json: bool,
1483 },
1484 List {
1487 #[arg(long)]
1488 json: bool,
1489 },
1490 Clear {
1493 #[arg(long)]
1494 json: bool,
1495 },
1496}
1497
1498#[derive(Subcommand, Debug)]
1499pub enum ServiceAction {
1500 Install {
1510 #[arg(long)]
1512 local_relay: bool,
1513 #[arg(long)]
1514 json: bool,
1515 },
1516 Uninstall {
1520 #[arg(long)]
1522 local_relay: bool,
1523 #[arg(long)]
1524 json: bool,
1525 },
1526 Status {
1528 #[arg(long)]
1530 local_relay: bool,
1531 #[arg(long)]
1532 json: bool,
1533 },
1534}
1535
1536#[derive(Subcommand, Debug)]
1537pub enum ResponderCommand {
1538 Set {
1540 status: String,
1542 #[arg(long)]
1544 reason: Option<String>,
1545 #[arg(long)]
1547 json: bool,
1548 },
1549 Get {
1551 peer: Option<String>,
1553 #[arg(long)]
1555 json: bool,
1556 },
1557}
1558
1559#[derive(Subcommand, Debug)]
1560pub enum ProfileAction {
1561 Set {
1565 field: String,
1566 value: String,
1567 #[arg(long)]
1568 json: bool,
1569 },
1570 Get {
1572 #[arg(long)]
1573 json: bool,
1574 },
1575 Clear {
1577 field: String,
1578 #[arg(long)]
1579 json: bool,
1580 },
1581}
1582
1583pub fn run() -> Result<()> {
1585 crate::session::maybe_adopt_session_wire_home("cli");
1596 let cli = Cli::parse();
1597 match cli.command {
1598 Command::Init {
1599 handle,
1600 name,
1601 relay,
1602 offline,
1603 json,
1604 } => cmd_init(
1605 Some(&handle),
1606 name.as_deref(),
1607 relay.as_deref(),
1608 offline,
1609 json,
1610 ),
1611 Command::Status { peer, json } => {
1612 if let Some(peer) = peer {
1613 cmd_status_peer(&peer, json)
1614 } else {
1615 cmd_status(json)
1616 }
1617 }
1618 Command::Whoami {
1619 json,
1620 short,
1621 colored,
1622 } => cmd_whoami(json_default(json), short, colored),
1623 Command::Peers { json } => cmd_peers(json_default(json)),
1624 Command::Here { json } => cmd_here(json_default(json)),
1625 Command::Completions { shell } => {
1626 use clap::CommandFactory;
1633 let mut cmd = Cli::command();
1634 clap_complete::generate(shell, &mut cmd, "wire", &mut std::io::stdout());
1635 Ok(())
1636 }
1637 Command::Pending { json } => cmd_pair_list_inbound(json_default(json)),
1638 Command::Reject { peer, json } => cmd_pair_reject(&peer, json_default(json)),
1639 Command::Send {
1640 peer,
1641 kind_or_body,
1642 body,
1643 deadline,
1644 no_auto_pair,
1645 json,
1646 } => {
1647 let (kind, body) = match body {
1650 Some(real_body) => (kind_or_body, real_body),
1651 None => ("claim".to_string(), kind_or_body),
1652 };
1653 cmd_send(
1654 &peer,
1655 &kind,
1656 &body,
1657 deadline.as_deref(),
1658 no_auto_pair,
1659 json_default(json),
1660 )
1661 }
1662 Command::Dial {
1663 name,
1664 message,
1665 json,
1666 } => cmd_dial(&name, message.as_deref(), json_default(json)),
1667 Command::Tail {
1668 peer,
1669 json,
1670 limit,
1671 oldest,
1672 } => cmd_tail(peer.as_deref(), json, limit, oldest),
1673 Command::Monitor {
1674 peer,
1675 json,
1676 include_handshake,
1677 interval_ms,
1678 replay,
1679 } => cmd_monitor(
1680 peer.as_deref(),
1681 json,
1682 include_handshake,
1683 interval_ms,
1684 replay,
1685 ),
1686 Command::Verify { path, json } => cmd_verify(&path, json),
1687 Command::Responder { command } => match command {
1688 ResponderCommand::Set {
1689 status,
1690 reason,
1691 json,
1692 } => cmd_responder_set(&status, reason.as_deref(), json),
1693 ResponderCommand::Get { peer, json } => cmd_responder_get(peer.as_deref(), json),
1694 },
1695 Command::Mcp => cmd_mcp(),
1696 Command::RelayServer {
1697 bind,
1698 local_only,
1699 uds,
1700 } => cmd_relay_server(&bind, local_only, uds.as_deref()),
1701 Command::BindRelay {
1702 url,
1703 scope,
1704 replace,
1705 migrate_pinned,
1706 json,
1707 } => cmd_bind_relay(&url, scope.as_deref(), replace, migrate_pinned, json),
1708 Command::AddPeerSlot {
1709 handle,
1710 url,
1711 slot_id,
1712 slot_token,
1713 json,
1714 } => cmd_add_peer_slot(&handle, &url, &slot_id, &slot_token, json),
1715 Command::Push { peer, json } => cmd_push(peer.as_deref(), json),
1716 Command::Pull { json } => cmd_pull(json),
1717 Command::Pin { card_file, json } => cmd_pin(&card_file, json),
1718 Command::RotateSlot { no_announce, json } => cmd_rotate_slot(no_announce, json),
1719 Command::ForgetPeer {
1720 handle,
1721 purge,
1722 json,
1723 } => cmd_forget_peer(&handle, purge, json),
1724 Command::Daemon {
1725 interval,
1726 once,
1727 json,
1728 } => cmd_daemon(interval, once, json),
1729 Command::PairHost {
1730 relay,
1731 yes,
1732 timeout,
1733 detach,
1734 json,
1735 } => {
1736 if detach {
1737 cmd_pair_host_detach(&relay, json)
1738 } else {
1739 cmd_pair_host(&relay, yes, timeout)
1740 }
1741 }
1742 Command::PairJoin {
1743 code_phrase,
1744 relay,
1745 yes,
1746 timeout,
1747 detach,
1748 json,
1749 } => {
1750 if detach {
1751 cmd_pair_join_detach(&code_phrase, &relay, json)
1752 } else {
1753 cmd_pair_join(&code_phrase, &relay, yes, timeout)
1754 }
1755 }
1756 Command::PairConfirm {
1757 code_phrase,
1758 digits,
1759 json,
1760 } => cmd_pair_confirm(&code_phrase, &digits, json),
1761 Command::PairList {
1762 json,
1763 watch,
1764 watch_interval,
1765 } => cmd_pair_list(json, watch, watch_interval),
1766 Command::PairCancel { code_phrase, json } => cmd_pair_cancel(&code_phrase, json),
1767 Command::PairWatch {
1768 code_phrase,
1769 status,
1770 timeout,
1771 json,
1772 } => cmd_pair_watch(&code_phrase, &status, timeout, json),
1773 Command::Pair {
1774 handle,
1775 code,
1776 relay,
1777 yes,
1778 timeout,
1779 no_setup,
1780 detach,
1781 } => {
1782 if handle.contains('@') && code.is_none() {
1789 cmd_pair_megacommand(&handle, Some(&relay), timeout, false)
1790 } else if detach {
1791 cmd_pair_detach(&handle, code.as_deref(), &relay)
1792 } else {
1793 cmd_pair(&handle, code.as_deref(), &relay, yes, timeout, no_setup)
1794 }
1795 }
1796 Command::PairAbandon { code_phrase, relay } => cmd_pair_abandon(&code_phrase, &relay),
1797 Command::PairAccept { peer, json } => {
1798 let j = json_default(json);
1799 deprecation_warn("pair-accept", &format!("accept {peer}"), j);
1800 cmd_pair_accept(&peer, j)
1801 }
1802 Command::PairReject { peer, json } => {
1803 let j = json_default(json);
1804 deprecation_warn("pair-reject", &format!("reject {peer}"), j);
1805 cmd_pair_reject(&peer, j)
1806 }
1807 Command::PairListInbound { json } => {
1808 let j = json_default(json);
1809 deprecation_warn("pair-list-inbound", "pending", j);
1810 cmd_pair_list_inbound(j)
1811 }
1812 Command::Session(cmd) => cmd_session(cmd),
1813 Command::Identity { cmd } => cmd_identity(cmd),
1814 Command::Mesh(cmd) => cmd_mesh(cmd),
1815 Command::Group(cmd) => cmd_group(cmd),
1816 Command::Enroll(cmd) => cmd_enroll(cmd),
1817 Command::Invite {
1818 relay,
1819 ttl,
1820 uses,
1821 share,
1822 json,
1823 } => cmd_invite(&relay, ttl, uses, share, json),
1824 Command::Accept { target, json } => {
1825 let j = json_default(json);
1831 if target.starts_with("wire://pair?") {
1832 deprecation_warn("accept-url", "accept-invite <url>", j);
1833 cmd_accept(&target, j)
1834 } else {
1835 cmd_pair_accept(&target, j)
1836 }
1837 }
1838 Command::AcceptInvite { url, json } => cmd_accept(&url, json_default(json)),
1839 Command::Whois {
1840 handle,
1841 json,
1842 relay,
1843 } => {
1844 match handle.as_deref() {
1853 Some(h) if !h.contains('@') => cmd_whois_local(h, json),
1854 other => cmd_whois(other, json, relay.as_deref()),
1855 }
1856 }
1857 Command::Add {
1858 handle,
1859 relay,
1860 local_sister,
1861 json,
1862 } => cmd_add(&handle, relay.as_deref(), local_sister, json),
1863 Command::Up {
1864 relay,
1865 name,
1866 with_local,
1867 no_local,
1868 json,
1869 } => cmd_up(
1870 relay.as_deref(),
1871 name.as_deref(),
1872 with_local.as_deref(),
1873 no_local,
1874 json,
1875 ),
1876 Command::Doctor {
1877 json,
1878 recent_rejections,
1879 } => cmd_doctor(json, recent_rejections),
1880 Command::Upgrade { check, local, json } => cmd_upgrade(check, local, json),
1881 Command::Service { action } => cmd_service(action),
1882 Command::Diag { action } => cmd_diag(action),
1883 Command::Claim {
1884 nick,
1885 relay,
1886 public_url,
1887 hidden,
1888 json,
1889 } => cmd_claim(&nick, relay.as_deref(), public_url.as_deref(), hidden, json),
1890 Command::Profile { action } => cmd_profile(action),
1891 Command::Setup {
1892 apply,
1893 statusline,
1894 remove,
1895 } => {
1896 if statusline {
1897 cmd_setup_statusline(apply, remove)
1898 } else {
1899 cmd_setup(apply)
1900 }
1901 }
1902 Command::Notify {
1903 interval,
1904 peer,
1905 once,
1906 json,
1907 } => cmd_notify(interval, peer.as_deref(), once, json),
1908 Command::Quiet { action } => cmd_quiet(action),
1909 }
1910}
1911
1912fn quiet_flag_path() -> Result<std::path::PathBuf> {
1919 Ok(config::config_dir()?.join("quiet"))
1920}
1921
1922fn cmd_quiet(action: QuietAction) -> Result<()> {
1923 match action {
1924 QuietAction::On => {
1925 let path = quiet_flag_path()?;
1926 if let Some(parent) = path.parent() {
1927 std::fs::create_dir_all(parent).with_context(|| {
1928 format!("creating config dir for quiet flag: {}", parent.display())
1929 })?;
1930 }
1931 std::fs::OpenOptions::new()
1933 .create(true)
1934 .truncate(true)
1935 .write(true)
1936 .open(&path)
1937 .with_context(|| format!("writing {}", path.display()))?;
1938 println!(
1939 "wire quiet: ON (toasts silenced — file at {})",
1940 path.display()
1941 );
1942 Ok(())
1943 }
1944 QuietAction::Off => {
1945 let path = quiet_flag_path()?;
1946 match std::fs::remove_file(&path) {
1947 Ok(()) => println!("wire quiet: OFF (toasts re-enabled)"),
1948 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1949 println!("wire quiet: OFF (was already off)")
1950 }
1951 Err(e) => return Err(anyhow!("removing {}: {e}", path.display())),
1952 }
1953 if std::env::var("WIRE_NO_TOASTS").is_ok_and(|v| !v.is_empty() && v != "0") {
1955 println!(
1956 " note: WIRE_NO_TOASTS={} is still set in env — toasts stay silenced for this process / daemon until `launchctl unsetenv WIRE_NO_TOASTS` (or unset in your shell).",
1957 std::env::var("WIRE_NO_TOASTS").unwrap_or_default()
1958 );
1959 }
1960 Ok(())
1961 }
1962 QuietAction::Status { json } => {
1963 let env_set = std::env::var("WIRE_NO_TOASTS").is_ok_and(|v| !v.is_empty() && v != "0");
1964 let file_present = quiet_flag_path()?.exists();
1965 let (state, via) = match (env_set, file_present) {
1966 (true, _) => ("on", "env"),
1967 (false, true) => ("on", "file"),
1968 (false, false) => ("off", "none"),
1969 };
1970 if json {
1971 println!(
1972 "{}",
1973 serde_json::to_string(&json!({
1974 "state": state,
1975 "via": via,
1976 "file": quiet_flag_path()?.display().to_string(),
1977 "env_WIRE_NO_TOASTS": std::env::var("WIRE_NO_TOASTS").ok(),
1978 }))?
1979 );
1980 } else {
1981 match (env_set, file_present) {
1982 (true, _) => println!(
1983 "wire quiet: ON (via WIRE_NO_TOASTS={} in env)",
1984 std::env::var("WIRE_NO_TOASTS").unwrap_or_default()
1985 ),
1986 (false, true) => println!(
1987 "wire quiet: ON (via file at {})",
1988 quiet_flag_path()?.display()
1989 ),
1990 (false, false) => println!("wire quiet: OFF"),
1991 }
1992 }
1993 Ok(())
1994 }
1995 }
1996}
1997
1998fn cmd_init(
2001 handle: Option<&str>,
2002 name: Option<&str>,
2003 relay: Option<&str>,
2004 offline: bool,
2005 as_json: bool,
2006) -> Result<()> {
2007 if let Some(h) = handle
2013 && !h
2014 .chars()
2015 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
2016 {
2017 bail!("handle must be ASCII alphanumeric / '-' / '_' (got {h:?})");
2018 }
2019 if config::is_initialized()? {
2020 bail!(
2021 "already initialized — config exists at {:?}. Delete it first if you want a fresh identity.",
2022 config::config_dir()?
2023 );
2024 }
2025 let mut resolved_relay: Option<String> = relay.map(str::to_string);
2039 if resolved_relay.is_none() && !offline {
2040 let default_local = "http://127.0.0.1:8771";
2041 let client = crate::relay_client::RelayClient::new(default_local);
2042 if client.check_healthz().is_ok() {
2043 eprintln!(
2044 "wire init: local relay at {default_local} reachable — auto-attaching. \
2045 Use --relay <url> to pick a different relay, --offline to skip."
2046 );
2047 resolved_relay = Some(default_local.to_string());
2048 } else {
2049 use std::io::{BufRead, IsTerminal, Write};
2055 let interactive = std::io::stdin().is_terminal() && std::io::stderr().is_terminal();
2056 if interactive && std::env::var("WIRE_NO_INTERACTIVE").is_err() {
2057 eprintln!("wire init: no local relay reachable at {default_local}.");
2058 eprint!(
2059 " Bind to public federation relay https://wireup.net instead? \
2060 [Y/n/offline/url]: "
2061 );
2062 let _ = std::io::stderr().flush();
2063 let mut input = String::new();
2064 let _ = std::io::stdin().lock().read_line(&mut input);
2065 let answer = input.trim();
2066 match answer {
2067 "" | "y" | "Y" | "yes" | "YES" => {
2068 eprintln!("wire init: binding to https://wireup.net");
2069 resolved_relay = Some("https://wireup.net".to_string());
2070 }
2071 "n" | "N" | "no" | "NO" => {
2072 bail!(
2073 "wire init: declined federation default; re-run with --relay <url> or --offline."
2074 );
2075 }
2076 "offline" | "OFFLINE" => {
2077 eprintln!(
2078 "wire init: proceeding offline. \
2079 Run `wire bind-relay <url>` before pairing."
2080 );
2081 }
2087 url if url.starts_with("http://") || url.starts_with("https://") => {
2088 eprintln!("wire init: binding to {url}");
2089 resolved_relay = Some(url.to_string());
2090 }
2091 other => {
2092 bail!(
2093 "wire init: unrecognized answer `{other}` — \
2094 expected Y/n/offline/<url>. Re-run with --relay or --offline."
2095 );
2096 }
2097 }
2098 } else {
2099 bail!(
2100 "wire init: no relay specified and no local relay reachable at \
2101 http://127.0.0.1:8771.\n\
2102 Pick one (or just run `wire up`):\n\
2103 • `wire service install --local-relay` — start the local relay, then re-run\n\
2104 • `wire up @wireup.net` — bind to public federation in one command\n\
2105 • `wire init --offline` — generate keypair only \
2106 (peers cannot reach you until you `wire bind-relay <url>` later)"
2107 );
2108 }
2109 }
2110 }
2111 let relay = resolved_relay.as_deref();
2112
2113 config::ensure_dirs()?;
2114 let (sk_seed, pk_bytes) = generate_keypair();
2115 config::write_private_key(&sk_seed)?;
2116
2117 let seed = handle.unwrap_or("agent");
2135 let synth_did = crate::agent_card::did_for_with_key(seed, &pk_bytes);
2136 let character = crate::character::Character::from_did(&synth_did);
2137 let canonical_handle: &str = &character.nickname;
2138 if let Some(typed) = handle
2139 && typed != canonical_handle
2140 {
2141 eprintln!(
2142 "wire init: one-name rule — typed `{typed}` ignored in favor of \
2143 DID-derived persona `{canonical_handle}`. Peers will reach you as `{canonical_handle}`."
2144 );
2145 }
2146
2147 let card = build_agent_card(canonical_handle, &pk_bytes, name, None, None);
2148 let card = crate::enroll::with_op_claims_if_enrolled(card)?;
2151 let signed = sign_agent_card(&card, &sk_seed);
2152 config::write_agent_card(&signed)?;
2153
2154 let mut trust = empty_trust();
2155 add_self_to_trust(&mut trust, canonical_handle, &pk_bytes);
2156 config::write_trust(&trust)?;
2157
2158 let fp = fingerprint(&pk_bytes);
2159 let key_id = make_key_id(canonical_handle, &pk_bytes);
2160 let handle = canonical_handle;
2163
2164 let mut relay_info: Option<(String, String)> = None;
2166 if let Some(url) = relay {
2167 let normalized = url.trim_end_matches('/');
2168 let client = crate::relay_client::RelayClient::new(normalized);
2169 client.check_healthz()?;
2170 let alloc = client.allocate_slot(Some(handle))?;
2171 let mut state = config::read_relay_state()?;
2172 state["self"] = json!({
2173 "relay_url": normalized,
2174 "slot_id": alloc.slot_id.clone(),
2175 "slot_token": alloc.slot_token,
2176 });
2177 config::write_relay_state(&state)?;
2178 relay_info = Some((normalized.to_string(), alloc.slot_id));
2179 }
2180
2181 let did_str = crate::agent_card::did_for_with_key(handle, &pk_bytes);
2182 if as_json {
2183 let mut out = json!({
2184 "did": did_str.clone(),
2185 "fingerprint": fp,
2186 "key_id": key_id,
2187 "config_dir": config::config_dir()?.to_string_lossy(),
2188 });
2189 if let Some((url, slot_id)) = &relay_info {
2190 out["relay_url"] = json!(url);
2191 out["slot_id"] = json!(slot_id);
2192 }
2193 println!("{}", serde_json::to_string(&out)?);
2194 } else {
2195 println!("generated {did_str} (ed25519:{key_id})");
2196 println!(
2197 "config written to {}",
2198 config::config_dir()?.to_string_lossy()
2199 );
2200 if let Some((url, slot_id)) = &relay_info {
2201 println!("bound to relay {url} (slot {slot_id})");
2202 println!();
2203 println!(
2204 "next step: `wire pair-host --relay {url}` to print a code phrase for a peer."
2205 );
2206 } else {
2207 println!();
2208 println!(
2209 "next step: `wire pair-host --relay <url>` to bind a relay + open a pair-slot."
2210 );
2211 }
2212 }
2213 Ok(())
2214}
2215
2216fn cmd_status(as_json: bool) -> Result<()> {
2219 let initialized = config::is_initialized()?;
2220
2221 let mut summary = json!({
2222 "initialized": initialized,
2223 });
2224
2225 if initialized {
2226 let card = config::read_agent_card()?;
2227 let did = card
2228 .get("did")
2229 .and_then(Value::as_str)
2230 .unwrap_or("")
2231 .to_string();
2232 let handle = card
2236 .get("handle")
2237 .and_then(Value::as_str)
2238 .map(str::to_string)
2239 .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
2240 let pk_b64 = card
2241 .get("verify_keys")
2242 .and_then(Value::as_object)
2243 .and_then(|m| m.values().next())
2244 .and_then(|v| v.get("key"))
2245 .and_then(Value::as_str)
2246 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
2247 let pk_bytes = crate::signing::b64decode(pk_b64)?;
2248 summary["did"] = json!(did);
2249 summary["handle"] = json!(handle);
2250 summary["fingerprint"] = json!(fingerprint(&pk_bytes));
2251 summary["capabilities"] = card
2252 .get("capabilities")
2253 .cloned()
2254 .unwrap_or_else(|| json!([]));
2255
2256 let trust = config::read_trust()?;
2257 let relay_state_for_tier =
2258 config::read_relay_state().unwrap_or_else(|_| json!({"peers": {}}));
2259 let mut peers = Vec::new();
2260 if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
2261 for (peer_handle, _agent) in agents {
2262 if peer_handle == &handle {
2263 continue; }
2265 peers.push(json!({
2270 "handle": peer_handle,
2271 "tier": effective_peer_tier(&trust, &relay_state_for_tier, peer_handle),
2272 }));
2273 }
2274 }
2275 summary["peers"] = json!(peers);
2276
2277 let relay_state = config::read_relay_state()?;
2278 summary["self_relay"] = relay_state.get("self").cloned().unwrap_or(Value::Null);
2279 if !summary["self_relay"].is_null() {
2280 if let Some(obj) = summary["self_relay"].as_object_mut() {
2282 obj.remove("slot_token");
2283 }
2284 }
2285 summary["peer_slots_count"] = json!(
2286 relay_state
2287 .get("peers")
2288 .and_then(Value::as_object)
2289 .map(|m| m.len())
2290 .unwrap_or(0)
2291 );
2292
2293 let outbox = config::outbox_dir()?;
2295 let inbox = config::inbox_dir()?;
2296 summary["outbox"] = json!(scan_jsonl_dir(&outbox)?);
2297 summary["inbox"] = json!(scan_jsonl_dir(&inbox)?);
2298
2299 let snap = crate::ensure_up::daemon_liveness();
2305 let mut daemon = json!({
2306 "running": snap.pidfile_alive,
2307 "pid": snap.pidfile_pid,
2308 "all_running_pids": snap.pgrep_pids,
2309 "orphans": snap.orphan_pids,
2310 });
2311 if let crate::ensure_up::PidRecord::Json(d) = &snap.record {
2312 daemon["version"] = json!(d.version);
2313 daemon["bin_path"] = json!(d.bin_path);
2314 daemon["did"] = json!(d.did);
2315 daemon["relay_url"] = json!(d.relay_url);
2316 daemon["started_at"] = json!(d.started_at);
2317 daemon["schema"] = json!(d.schema);
2318 if d.version != env!("CARGO_PKG_VERSION") {
2319 daemon["version_mismatch"] = json!({
2320 "daemon": d.version.clone(),
2321 "cli": env!("CARGO_PKG_VERSION"),
2322 });
2323 }
2324 } else if matches!(snap.record, crate::ensure_up::PidRecord::LegacyInt(_)) {
2325 daemon["pidfile_form"] = json!("legacy-int");
2326 daemon["version_mismatch"] = json!({
2327 "daemon": "<pre-0.5.11>",
2328 "cli": env!("CARGO_PKG_VERSION"),
2329 });
2330 }
2331 summary["daemon"] = daemon;
2332
2333 let pending = crate::pending_pair::list_pending().unwrap_or_default();
2335 let mut counts: std::collections::BTreeMap<String, u32> = Default::default();
2336 for p in &pending {
2337 *counts.entry(p.status.clone()).or_default() += 1;
2338 }
2339 let pending_inbound =
2341 crate::pending_inbound_pair::list_pending_inbound().unwrap_or_default();
2342 let inbound_handles: Vec<&str> = pending_inbound
2343 .iter()
2344 .map(|p| p.peer_handle.as_str())
2345 .collect();
2346 summary["pending_pairs"] = json!({
2347 "total": pending.len(),
2348 "by_status": counts,
2349 "inbound_count": pending_inbound.len(),
2350 "inbound_handles": inbound_handles,
2351 });
2352 }
2353
2354 if as_json {
2355 println!("{}", serde_json::to_string(&summary)?);
2356 } else if !initialized {
2357 println!("not initialized — run `wire init <handle>` first");
2358 } else {
2359 println!("did: {}", summary["did"].as_str().unwrap_or("?"));
2360 println!(
2361 "fingerprint: {}",
2362 summary["fingerprint"].as_str().unwrap_or("?")
2363 );
2364 println!("capabilities: {}", summary["capabilities"]);
2365 if !summary["self_relay"].is_null() {
2366 println!(
2367 "self relay: {} (slot {})",
2368 summary["self_relay"]["relay_url"].as_str().unwrap_or("?"),
2369 summary["self_relay"]["slot_id"].as_str().unwrap_or("?")
2370 );
2371 } else {
2372 println!("self relay: (not bound — run `wire pair-host --relay <url>` to bind)");
2373 }
2374 println!(
2375 "peers: {}",
2376 summary["peers"].as_array().map(|a| a.len()).unwrap_or(0)
2377 );
2378 for p in summary["peers"].as_array().unwrap_or(&Vec::new()) {
2379 println!(
2380 " - {:<20} tier={}",
2381 p["handle"].as_str().unwrap_or(""),
2382 p["tier"].as_str().unwrap_or("?")
2383 );
2384 }
2385 println!(
2386 "outbox: {} file(s), {} event(s) queued",
2387 summary["outbox"]["files"].as_u64().unwrap_or(0),
2388 summary["outbox"]["events"].as_u64().unwrap_or(0)
2389 );
2390 println!(
2391 "inbox: {} file(s), {} event(s) received",
2392 summary["inbox"]["files"].as_u64().unwrap_or(0),
2393 summary["inbox"]["events"].as_u64().unwrap_or(0)
2394 );
2395 let daemon_running = summary["daemon"]["running"].as_bool().unwrap_or(false);
2396 let daemon_pid = summary["daemon"]["pid"]
2397 .as_u64()
2398 .map(|p| p.to_string())
2399 .unwrap_or_else(|| "—".to_string());
2400 let daemon_version = summary["daemon"]["version"].as_str().unwrap_or("");
2401 let version_suffix = if !daemon_version.is_empty() {
2402 format!(" v{daemon_version}")
2403 } else {
2404 String::new()
2405 };
2406 println!(
2407 "daemon: {} (pid {}{})",
2408 if daemon_running { "running" } else { "DOWN" },
2409 daemon_pid,
2410 version_suffix,
2411 );
2412 if let Some(mm) = summary["daemon"].get("version_mismatch") {
2414 println!(
2415 " !! version mismatch: daemon={} CLI={}. \
2416 run `wire upgrade` to swap atomically.",
2417 mm["daemon"].as_str().unwrap_or("?"),
2418 mm["cli"].as_str().unwrap_or("?"),
2419 );
2420 }
2421 if let Some(orphans) = summary["daemon"]["orphans"].as_array()
2422 && !orphans.is_empty()
2423 {
2424 let pids: Vec<String> = orphans
2425 .iter()
2426 .filter_map(|v| v.as_u64().map(|p| p.to_string()))
2427 .collect();
2428 println!(
2429 " !! orphan daemon process(es): pids {}. \
2430 pgrep saw them but pidfile didn't — likely stale process from \
2431 prior install. Multiple daemons race the relay cursor.",
2432 pids.join(", ")
2433 );
2434 }
2435 let pending_total = summary["pending_pairs"]["total"].as_u64().unwrap_or(0);
2436 let inbound_count = summary["pending_pairs"]["inbound_count"]
2437 .as_u64()
2438 .unwrap_or(0);
2439 if pending_total > 0 {
2440 print!("pending pairs: {pending_total}");
2441 if let Some(obj) = summary["pending_pairs"]["by_status"].as_object() {
2442 let parts: Vec<String> = obj
2443 .iter()
2444 .map(|(k, v)| format!("{}={}", k, v.as_u64().unwrap_or(0)))
2445 .collect();
2446 if !parts.is_empty() {
2447 print!(" ({})", parts.join(", "));
2448 }
2449 }
2450 println!();
2451 } else if inbound_count == 0 {
2452 println!("pending pairs: none");
2453 }
2454 if inbound_count > 0 {
2458 let handles: Vec<String> = summary["pending_pairs"]["inbound_handles"]
2459 .as_array()
2460 .map(|a| {
2461 a.iter()
2462 .filter_map(|v| v.as_str().map(str::to_string))
2463 .collect()
2464 })
2465 .unwrap_or_default();
2466 println!(
2467 "inbound pair requests ({inbound_count}): {} — `wire pair-list` to inspect, `wire pair-accept <peer>` to accept, `wire pair-reject <peer>` to refuse",
2468 handles.join(", "),
2469 );
2470 }
2471 }
2472 Ok(())
2473}
2474
2475fn scan_jsonl_dir(dir: &std::path::Path) -> Result<Value> {
2476 if !dir.exists() {
2477 return Ok(json!({"files": 0, "events": 0}));
2478 }
2479 let mut files = 0usize;
2480 let mut events = 0usize;
2481 for entry in std::fs::read_dir(dir)? {
2482 let path = entry?.path();
2483 if path.extension().map(|x| x == "jsonl").unwrap_or(false) {
2484 files += 1;
2485 if let Ok(body) = std::fs::read_to_string(&path) {
2486 events += body.lines().filter(|l| !l.trim().is_empty()).count();
2487 }
2488 }
2489 }
2490 Ok(json!({"files": files, "events": events}))
2491}
2492
2493fn responder_status_allowed(status: &str) -> bool {
2496 matches!(
2497 status,
2498 "online" | "offline" | "oauth_locked" | "rate_limited" | "degraded"
2499 )
2500}
2501
2502fn relay_slot_for(peer: Option<&str>) -> Result<(String, String, String, String)> {
2503 let state = config::read_relay_state()?;
2504 let (label, slot_info) = match peer {
2505 Some(peer) => (
2506 peer.to_string(),
2507 state
2508 .get("peers")
2509 .and_then(|p| p.get(peer))
2510 .ok_or_else(|| {
2511 anyhow!(
2512 "unknown peer {peer:?} in relay state — pair with them first:\n \
2513 wire add {peer}@wireup.net (or {peer}@<their-relay>)\n\
2514 (`wire peers` lists who you've already paired with.)"
2515 )
2516 })?,
2517 ),
2518 None => (
2519 "self".to_string(),
2520 state.get("self").filter(|v| !v.is_null()).ok_or_else(|| {
2521 anyhow!("self slot not bound — run `wire bind-relay <url>` first")
2522 })?,
2523 ),
2524 };
2525 let relay_url = slot_info["relay_url"]
2526 .as_str()
2527 .ok_or_else(|| anyhow!("{label} relay_url missing"))?
2528 .to_string();
2529 let slot_id = slot_info["slot_id"]
2530 .as_str()
2531 .ok_or_else(|| anyhow!("{label} slot_id missing"))?
2532 .to_string();
2533 let slot_token = slot_info["slot_token"]
2534 .as_str()
2535 .ok_or_else(|| anyhow!("{label} slot_token missing"))?
2536 .to_string();
2537 Ok((label, relay_url, slot_id, slot_token))
2538}
2539
2540fn cmd_responder_set(status: &str, reason: Option<&str>, as_json: bool) -> Result<()> {
2541 if !responder_status_allowed(status) {
2542 bail!("status must be one of: online, offline, oauth_locked, rate_limited, degraded");
2543 }
2544 let (_label, relay_url, slot_id, slot_token) = relay_slot_for(None)?;
2545 let now = time::OffsetDateTime::now_utc()
2546 .format(&time::format_description::well_known::Rfc3339)
2547 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
2548 let mut record = json!({
2549 "status": status,
2550 "set_at": now,
2551 });
2552 if let Some(reason) = reason {
2553 record["reason"] = json!(reason);
2554 }
2555 if status == "online" {
2556 record["last_success_at"] = json!(now);
2557 }
2558 let client = crate::relay_client::RelayClient::new(&relay_url);
2559 let saved = client.responder_health_set(&slot_id, &slot_token, &record)?;
2560 if as_json {
2561 println!("{}", serde_json::to_string(&saved)?);
2562 } else {
2563 let reason = saved
2564 .get("reason")
2565 .and_then(Value::as_str)
2566 .map(|r| format!(" — {r}"))
2567 .unwrap_or_default();
2568 println!(
2569 "responder {}{}",
2570 saved
2571 .get("status")
2572 .and_then(Value::as_str)
2573 .unwrap_or(status),
2574 reason
2575 );
2576 }
2577 Ok(())
2578}
2579
2580fn cmd_responder_get(peer: Option<&str>, as_json: bool) -> Result<()> {
2581 let (label, relay_url, slot_id, slot_token) = relay_slot_for(peer)?;
2582 let client = crate::relay_client::RelayClient::new(&relay_url);
2583 let health = client.responder_health_get(&slot_id, &slot_token)?;
2584 if as_json {
2585 println!(
2586 "{}",
2587 serde_json::to_string(&json!({
2588 "target": label,
2589 "responder_health": health,
2590 }))?
2591 );
2592 } else if health.is_null() {
2593 println!("{label}: responder health not reported");
2594 } else {
2595 let status = health
2596 .get("status")
2597 .and_then(Value::as_str)
2598 .unwrap_or("unknown");
2599 let reason = health
2600 .get("reason")
2601 .and_then(Value::as_str)
2602 .map(|r| format!(" — {r}"))
2603 .unwrap_or_default();
2604 let last_success = health
2605 .get("last_success_at")
2606 .and_then(Value::as_str)
2607 .map(|t| format!(" (last_success: {t})"))
2608 .unwrap_or_default();
2609 println!("{label}: {status}{reason}{last_success}");
2610 }
2611 Ok(())
2612}
2613
2614fn cmd_status_peer(peer: &str, as_json: bool) -> Result<()> {
2615 let (_label, relay_url, slot_id, slot_token) = relay_slot_for(Some(peer))?;
2616 let client = crate::relay_client::RelayClient::new(&relay_url);
2617
2618 let started = std::time::Instant::now();
2619 let transport_ok = client.healthz().unwrap_or(false);
2620 let latency_ms = started.elapsed().as_millis() as u64;
2621
2622 let (event_count, last_pull_at_unix) = client.slot_state(&slot_id, &slot_token)?;
2623 let now = std::time::SystemTime::now()
2624 .duration_since(std::time::UNIX_EPOCH)
2625 .map(|d| d.as_secs())
2626 .unwrap_or(0);
2627 let attention = match last_pull_at_unix {
2628 Some(last) if now.saturating_sub(last) <= 300 => json!({
2629 "status": "ok",
2630 "last_pull_at_unix": last,
2631 "age_seconds": now.saturating_sub(last),
2632 "event_count": event_count,
2633 }),
2634 Some(last) => json!({
2635 "status": "stale",
2636 "last_pull_at_unix": last,
2637 "age_seconds": now.saturating_sub(last),
2638 "event_count": event_count,
2639 }),
2640 None => json!({
2641 "status": "never_pulled",
2642 "last_pull_at_unix": Value::Null,
2643 "event_count": event_count,
2644 }),
2645 };
2646
2647 let responder_health = client.responder_health_get(&slot_id, &slot_token)?;
2648 let responder = if responder_health.is_null() {
2649 json!({"status": "not_reported", "record": Value::Null})
2650 } else {
2651 json!({
2652 "status": responder_health
2653 .get("status")
2654 .and_then(Value::as_str)
2655 .unwrap_or("unknown"),
2656 "record": responder_health,
2657 })
2658 };
2659
2660 let report = json!({
2661 "peer": peer,
2662 "transport": {
2663 "status": if transport_ok { "ok" } else { "error" },
2664 "relay_url": relay_url,
2665 "latency_ms": latency_ms,
2666 },
2667 "attention": attention,
2668 "responder": responder,
2669 });
2670
2671 if as_json {
2672 println!("{}", serde_json::to_string(&report)?);
2673 } else {
2674 let transport_line = if transport_ok {
2675 format!("ok relay reachable ({latency_ms}ms)")
2676 } else {
2677 "error relay unreachable".to_string()
2678 };
2679 println!("transport {transport_line}");
2680 match report["attention"]["status"].as_str().unwrap_or("unknown") {
2681 "ok" => println!(
2682 "attention ok last pull {}s ago",
2683 report["attention"]["age_seconds"].as_u64().unwrap_or(0)
2684 ),
2685 "stale" => println!(
2686 "attention stale last pull {}m ago",
2687 report["attention"]["age_seconds"].as_u64().unwrap_or(0) / 60
2688 ),
2689 "never_pulled" => println!("attention never pulled since relay reset"),
2690 other => println!("attention {other}"),
2691 }
2692 if report["responder"]["status"] == "not_reported" {
2693 println!("auto-responder not reported");
2694 } else {
2695 let record = &report["responder"]["record"];
2696 let status = record
2697 .get("status")
2698 .and_then(Value::as_str)
2699 .unwrap_or("unknown");
2700 let reason = record
2701 .get("reason")
2702 .and_then(Value::as_str)
2703 .map(|r| format!(" — {r}"))
2704 .unwrap_or_default();
2705 println!("auto-responder {status}{reason}");
2706 }
2707 }
2708 Ok(())
2709}
2710
2711fn current_cwd_display() -> String {
2719 let cwd = match std::env::current_dir() {
2720 Ok(c) => c,
2721 Err(_) => return String::from("?"),
2722 };
2723 if let Some(home) = dirs::home_dir()
2724 && let Ok(rel) = cwd.strip_prefix(&home)
2725 {
2726 let rel_str = rel.to_string_lossy();
2728 if rel_str.is_empty() {
2729 return String::from("~");
2730 }
2731 return format!("~/{rel_str}");
2732 }
2733 cwd.to_string_lossy().into_owned()
2734}
2735
2736pub(crate) fn op_claims_from_card(card: &Value) -> serde_json::Map<String, Value> {
2750 let mut out = serde_json::Map::new();
2751 for key in [
2752 "op_did",
2753 "op_pubkey",
2754 "op_cert",
2755 "org_memberships",
2756 "schema_version",
2757 ] {
2758 if let Some(v) = card.get(key)
2759 && !v.is_null()
2760 {
2761 out.insert(key.to_string(), v.clone());
2762 }
2763 }
2764 out
2765}
2766
2767fn cmd_whoami(as_json: bool, short: bool, colored: bool) -> Result<()> {
2768 if !config::is_initialized()? {
2769 bail!("not initialized — run `wire init <handle>` first");
2770 }
2771 let card = config::read_agent_card()?;
2772 let did = card
2773 .get("did")
2774 .and_then(Value::as_str)
2775 .unwrap_or("")
2776 .to_string();
2777 let handle = card
2778 .get("handle")
2779 .and_then(Value::as_str)
2780 .map(str::to_string)
2781 .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
2782 let character = crate::character::Character::from_did(&did);
2786
2787 let cwd_display = current_cwd_display();
2793
2794 if short {
2797 println!("{} · {}", character.short(), cwd_display);
2798 return Ok(());
2799 }
2800 if colored {
2801 println!("{} \x1b[2m·\x1b[0m {}", character.colored(), cwd_display);
2802 return Ok(());
2803 }
2804
2805 let pk_b64 = card
2806 .get("verify_keys")
2807 .and_then(Value::as_object)
2808 .and_then(|m| m.values().next())
2809 .and_then(|v| v.get("key"))
2810 .and_then(Value::as_str)
2811 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
2812 let pk_bytes = crate::signing::b64decode(pk_b64)?;
2813 let fp = fingerprint(&pk_bytes);
2814 let key_id = make_key_id(&handle, &pk_bytes);
2815 let capabilities = card
2816 .get("capabilities")
2817 .cloned()
2818 .unwrap_or_else(|| json!(["wire/v3.1"]));
2819
2820 if as_json {
2821 let has_override = false;
2825 let mut payload = serde_json::Map::new();
2826 payload.insert("did".into(), json!(did));
2827 payload.insert("handle".into(), json!(handle));
2828 payload.insert("fingerprint".into(), json!(fp));
2829 payload.insert("key_id".into(), json!(key_id));
2830 payload.insert("public_key_b64".into(), json!(pk_b64));
2831 payload.insert("capabilities".into(), capabilities);
2832 payload.insert(
2833 "config_dir".into(),
2834 json!(config::config_dir()?.to_string_lossy()),
2835 );
2836 payload.insert("persona".into(), serde_json::to_value(&character)?);
2837 payload.insert("persona_override".into(), json!(has_override));
2838 for (k, v) in op_claims_from_card(&card) {
2842 payload.insert(k, v);
2843 }
2844 println!("{}", serde_json::to_string(&payload)?);
2845 } else {
2846 println!("{}", character.colored());
2847 println!("{did} (ed25519:{key_id})");
2848 println!("fingerprint: {fp}");
2849 println!("capabilities: {capabilities}");
2850 if let Some(op_did) = card.get("op_did").and_then(Value::as_str) {
2855 let memberships = card
2856 .get("org_memberships")
2857 .and_then(Value::as_array)
2858 .map(|a| a.len())
2859 .unwrap_or(0);
2860 let plural = if memberships == 1 { "" } else { "s" };
2861 println!("enrolled: {op_did} ({memberships} org membership{plural})");
2862 }
2863 }
2864 Ok(())
2865}
2866
2867fn cmd_enroll(cmd: EnrollCommand) -> Result<()> {
2870 match cmd {
2871 EnrollCommand::Op { handle, json } => {
2872 let (sk, pk) = crate::signing::generate_keypair();
2873 crate::config::write_op_key(&sk)?;
2874 crate::config::write_op_handle(&handle)?;
2875 let op_did = crate::agent_card::did_for_op(&handle, &pk);
2876 let op_pubkey = crate::signing::b64encode(&pk);
2877 if json {
2878 println!(
2879 "{}",
2880 serde_json::to_string(&json!({"op_did": op_did, "op_pubkey": op_pubkey}))?
2881 );
2882 } else {
2883 println!(
2884 "→ operator enrolled\n op_did: {op_did}\n op_pubkey: {op_pubkey}\n key saved 0600 at {:?}",
2885 crate::config::op_key_path()?
2886 );
2887 }
2888 Ok(())
2889 }
2890 EnrollCommand::OrgCreate { handle, json } => {
2891 let (sk, pk) = crate::signing::generate_keypair();
2892 let org_did = crate::agent_card::did_for_org(&handle, &pk);
2893 crate::config::write_org_key(&org_did, &sk)?;
2894 let org_pubkey = crate::signing::b64encode(&pk);
2895 if json {
2896 println!(
2897 "{}",
2898 serde_json::to_string(&json!({"org_did": org_did, "org_pubkey": org_pubkey}))?
2899 );
2900 } else {
2901 println!(
2902 "→ organization created\n org_did: {org_did}\n org_pubkey: {org_pubkey}\n key saved 0600 at {:?}",
2903 crate::config::org_key_path(&org_did)?
2904 );
2905 }
2906 Ok(())
2907 }
2908 EnrollCommand::OrgAddMember { op_did, org, json } => {
2909 if !crate::agent_card::is_op_did(&op_did) {
2910 bail!("not a valid operator DID (did:wire:op:<handle>-<32hex>): {op_did}");
2911 }
2912 let org_sk = crate::config::read_org_key(&org).with_context(|| {
2913 format!("no stored key for org {org} — run `wire enroll org-create` first")
2914 })?;
2915 let org_pk = ed25519_dalek::SigningKey::from_bytes(&org_sk)
2916 .verifying_key()
2917 .to_bytes();
2918 let member_cert = crate::enroll::issue_member_cert(&org_sk, &op_did)?;
2919 let org_pubkey = crate::signing::b64encode(&org_pk);
2920 crate::config::add_membership(&org, &org_pubkey, &member_cert)?;
2923 if json {
2924 println!(
2925 "{}",
2926 serde_json::to_string(&json!({
2927 "org_did": org, "org_pubkey": org_pubkey, "member_cert": member_cert
2928 }))?
2929 );
2930 } else {
2931 println!(
2932 "→ membership issued for {op_did}\n add to the operator's card org_memberships[]:\n {{\"org_did\": \"{org}\", \"org_pubkey\": \"{org_pubkey}\", \"member_cert\": \"{member_cert}\"}}"
2933 );
2934 }
2935 Ok(())
2936 }
2937 EnrollCommand::Republish { json } => {
2938 let card = crate::enroll::rebuild_card_with_current_claims()?;
2942 let published = republish_card_to_phonebook();
2943 let op_did = card
2944 .get("op_did")
2945 .and_then(Value::as_str)
2946 .map(str::to_string);
2947 let n_memberships = card
2948 .get("org_memberships")
2949 .and_then(Value::as_array)
2950 .map(Vec::len)
2951 .unwrap_or(0);
2952 if json {
2953 println!(
2954 "{}",
2955 serde_json::to_string(&json!({
2956 "op_did": op_did,
2957 "org_memberships": n_memberships,
2958 "published": published,
2959 }))?
2960 );
2961 } else {
2962 match op_did {
2963 Some(did) => println!(
2964 "→ card rebuilt with current enrollment\n op_did: {did}\n memberships: {n_memberships}"
2965 ),
2966 None => println!(
2967 "→ card rebuilt — no operator enrolled (claims stripped if previously present)"
2968 ),
2969 }
2970 print_profile_publish_result(&published);
2971 }
2972 Ok(())
2973 }
2974 }
2975}
2976
2977fn cmd_identity(cmd: IdentityCommand) -> Result<()> {
2978 match cmd {
2979 IdentityCommand::Show { json } => cmd_whoami(json, !json, false),
2986 IdentityCommand::List { json } => cmd_session_list(json),
2987 IdentityCommand::Publish {
2988 nick,
2989 relay,
2990 public_url,
2991 hidden,
2992 json,
2993 } => cmd_claim(&nick, relay.as_deref(), public_url.as_deref(), hidden, json),
2994 IdentityCommand::Destroy { name, force, json } => cmd_session_destroy(&name, force, json),
2995 IdentityCommand::Create {
2996 name,
2997 anonymous,
2998 local: _,
2999 json,
3000 } => cmd_identity_create(name.as_deref(), anonymous, json),
3001 IdentityCommand::Persist {
3002 name,
3003 as_name,
3004 json,
3005 } => cmd_identity_persist(&name, as_name.as_deref(), json),
3006 IdentityCommand::Demote { name, json } => cmd_identity_demote(&name, json),
3007 }
3008}
3009
3010fn cmd_identity_create(name: Option<&str>, anonymous: bool, as_json: bool) -> Result<()> {
3015 if anonymous {
3016 let rand_suffix = format!("{:08x}", rand::random::<u32>());
3018 let anon_name = name
3019 .map(crate::session::sanitize_name)
3020 .unwrap_or_else(|| format!("anon-{rand_suffix}"));
3021 let anon_root = std::env::temp_dir().join(format!("wire-anon-{rand_suffix}"));
3022 std::fs::create_dir_all(&anon_root)
3023 .with_context(|| format!("creating anon root {anon_root:?}"))?;
3024 let session_home = anon_root.join("sessions").join(&anon_name);
3026 std::fs::create_dir_all(&session_home)?;
3027 let status = run_wire_with_home(&session_home, &["init", &anon_name, "--offline"])?;
3028 if !status.success() {
3029 bail!("anonymous identity init failed: {status}");
3030 }
3031 let marker = anon_root.join("anon-marker.json");
3034 std::fs::write(
3035 &marker,
3036 serde_json::to_vec_pretty(&serde_json::json!({
3037 "name": anon_name,
3038 "session_home": session_home.to_string_lossy(),
3039 "created_at": time::OffsetDateTime::now_utc()
3040 .format(&time::format_description::well_known::Rfc3339)
3041 .unwrap_or_default(),
3042 "kind": "anonymous",
3043 }))?,
3044 )?;
3045 let card = serde_json::from_slice::<Value>(&std::fs::read(
3046 session_home
3047 .join("config")
3048 .join("wire")
3049 .join("agent-card.json"),
3050 )?)?;
3051 let did = card
3052 .get("did")
3053 .and_then(Value::as_str)
3054 .unwrap_or("")
3055 .to_string();
3056 if as_json {
3057 println!(
3058 "{}",
3059 serde_json::to_string(&json!({
3060 "kind": "anonymous",
3061 "name": anon_name,
3062 "did": did,
3063 "session_home": session_home.to_string_lossy(),
3064 "anon_root": anon_root.to_string_lossy(),
3065 }))?
3066 );
3067 } else {
3068 println!("created anonymous identity `{anon_name}` ({did})");
3069 println!(
3070 " session_home: {} (dies on reboot — /tmp)",
3071 session_home.display()
3072 );
3073 println!();
3074 println!("activate in this shell:");
3075 println!(" export WIRE_HOME={}", session_home.display());
3076 println!();
3077 println!("promote to persistent later with:");
3078 println!(" wire identity persist {anon_name}");
3079 }
3080 return Ok(());
3081 }
3082 let name_arg = name.map(|s| s.to_string());
3084 cmd_session_new(
3085 name_arg.as_deref(),
3086 "https://wireup.net",
3087 false,
3088 "http://127.0.0.1:8771",
3089 false,
3090 None,
3091 false,
3092 None,
3093 true, true, as_json,
3096 )
3097}
3098
3099fn cmd_identity_persist(name: &str, as_name: Option<&str>, as_json: bool) -> Result<()> {
3102 let temp = std::env::temp_dir();
3104 let mut found: Option<(std::path::PathBuf, std::path::PathBuf)> = None;
3105 for entry in std::fs::read_dir(&temp)?.flatten() {
3106 let path = entry.path();
3107 if !path
3108 .file_name()
3109 .and_then(|s| s.to_str())
3110 .map(|s| s.starts_with("wire-anon-"))
3111 .unwrap_or(false)
3112 {
3113 continue;
3114 }
3115 let marker = path.join("anon-marker.json");
3116 if let Ok(bytes) = std::fs::read(&marker)
3117 && let Ok(json) = serde_json::from_slice::<Value>(&bytes)
3118 && json.get("name").and_then(Value::as_str) == Some(name)
3119 {
3120 let session_home = json
3121 .get("session_home")
3122 .and_then(Value::as_str)
3123 .map(std::path::PathBuf::from)
3124 .ok_or_else(|| anyhow!("anon-marker {marker:?} missing session_home"))?;
3125 found = Some((path, session_home));
3126 break;
3127 }
3128 }
3129 let (anon_root, anon_session_home) = found.ok_or_else(|| {
3130 anyhow!(
3131 "no anonymous identity named `{name}` found in /tmp/wire-anon-* — \
3132 run `wire identity list` to see available identities"
3133 )
3134 })?;
3135
3136 let new_name = as_name.unwrap_or(name);
3137 let new_session_home = crate::session::session_dir(new_name)?;
3138 if new_session_home.exists() {
3139 bail!(
3140 "target session `{new_name}` already exists at {new_session_home:?} — \
3141 pick a different name with --as <new-name>"
3142 );
3143 }
3144
3145 if let Some(parent) = new_session_home.parent() {
3147 std::fs::create_dir_all(parent)?;
3148 }
3149 std::fs::rename(&anon_session_home, &new_session_home)
3150 .with_context(|| format!("rename {anon_session_home:?} → {new_session_home:?}"))?;
3151
3152 let _ = std::fs::remove_dir_all(&anon_root);
3154
3155 let cwd = std::env::current_dir().unwrap_or_else(|_| new_session_home.clone());
3158 let cwd_key = crate::session::normalize_cwd_key(&cwd);
3159 let new_name_for_reg = new_name.to_string();
3160 if let Err(e) = crate::session::update_registry(|reg| {
3161 reg.by_cwd.insert(cwd_key, new_name_for_reg);
3162 Ok(())
3163 }) {
3164 eprintln!("wire identity persist: failed to update registry: {e:#}");
3165 }
3166
3167 if as_json {
3168 println!(
3169 "{}",
3170 serde_json::to_string(&json!({
3171 "kind": "persisted",
3172 "from_name": name,
3173 "to_name": new_name,
3174 "session_home": new_session_home.to_string_lossy(),
3175 }))?
3176 );
3177 } else {
3178 println!("persisted anonymous identity `{name}` → local session `{new_name}`");
3179 println!(
3180 " session_home: {} (survives reboot)",
3181 new_session_home.display()
3182 );
3183 println!(" registered cwd: {}", cwd.display());
3184 }
3185 Ok(())
3186}
3187
3188fn cmd_identity_demote(name: &str, as_json: bool) -> Result<()> {
3194 let sessions = crate::session::list_sessions()?;
3195 let session = sessions
3196 .iter()
3197 .find(|s| s.name == name)
3198 .ok_or_else(|| anyhow!("no session named `{name}` (run `wire identity list`)"))?;
3199 let relay_state_path = session
3200 .home_dir
3201 .join("config")
3202 .join("wire")
3203 .join("relay.json");
3204 if !relay_state_path.exists() {
3205 bail!("session `{name}` has no relay state — already demoted?");
3206 }
3207 let mut state: Value = serde_json::from_slice(&std::fs::read(&relay_state_path)?)?;
3208 let self_obj = state.get("self").cloned().unwrap_or(Value::Null);
3209 let had_fed = self_obj
3210 .get("relay_url")
3211 .and_then(Value::as_str)
3212 .map(|u| {
3213 u.starts_with("https://") || (u.starts_with("http://") && !u.contains("127.0.0.1"))
3214 })
3215 .unwrap_or(false);
3216 if !had_fed {
3217 if as_json {
3218 println!(
3219 "{}",
3220 serde_json::to_string(
3221 &json!({"name": name, "status": "no-op", "reason": "no federation slot"})
3222 )?
3223 );
3224 } else {
3225 println!("session `{name}` has no federation slot — nothing to demote");
3226 }
3227 return Ok(());
3228 }
3229 if let Some(self_mut) = state
3232 .as_object_mut()
3233 .and_then(|m| m.get_mut("self"))
3234 .and_then(|s| s.as_object_mut())
3235 {
3236 self_mut.remove("relay_url");
3237 self_mut.remove("slot_id");
3238 self_mut.remove("slot_token");
3239 if let Some(eps) = self_mut.get_mut("endpoints").and_then(|e| e.as_array_mut()) {
3240 eps.retain(|ep| ep.get("scope").and_then(Value::as_str) != Some("federation"));
3241 }
3242 }
3243 std::fs::write(&relay_state_path, serde_json::to_vec_pretty(&state)?)?;
3244
3245 if as_json {
3246 println!(
3247 "{}",
3248 serde_json::to_string(
3249 &json!({"name": name, "status": "demoted", "from": "federation", "to": "local"})
3250 )?
3251 );
3252 } else {
3253 println!("demoted `{name}` from federation → local");
3254 println!(" relay slot binding removed; keypair + agent-card retained");
3255 println!(" re-publish with `wire identity publish <nick>`");
3256 }
3257 Ok(())
3258}
3259
3260fn effective_peer_tier(trust: &Value, relay_state: &Value, handle: &str) -> String {
3261 let raw = crate::trust::get_tier(trust, handle);
3262 if raw != "VERIFIED" {
3263 return raw.to_string();
3264 }
3265 let token = relay_state
3266 .get("peers")
3267 .and_then(|p| p.get(handle))
3268 .and_then(|p| p.get("slot_token"))
3269 .and_then(Value::as_str)
3270 .unwrap_or("");
3271 if token.is_empty() {
3272 "PENDING_ACK".to_string()
3273 } else {
3274 raw.to_string()
3275 }
3276}
3277
3278fn cmd_peers(as_json: bool) -> Result<()> {
3279 let trust = config::read_trust()?;
3280 let agents = trust
3281 .get("agents")
3282 .and_then(Value::as_object)
3283 .cloned()
3284 .unwrap_or_default();
3285 let relay_state = config::read_relay_state().unwrap_or_else(|_| json!({"peers": {}}));
3286
3287 let mut self_did: Option<String> = None;
3288 if let Ok(card) = config::read_agent_card() {
3289 self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
3290 }
3291
3292 let mut peers = Vec::new();
3293 for (handle, agent) in agents.iter() {
3294 let did = agent
3295 .get("did")
3296 .and_then(Value::as_str)
3297 .unwrap_or("")
3298 .to_string();
3299 if Some(did.as_str()) == self_did.as_deref() {
3300 continue; }
3302 let tier = effective_peer_tier(&trust, &relay_state, handle);
3303 let capabilities = agent
3304 .get("card")
3305 .and_then(|c| c.get("capabilities"))
3306 .cloned()
3307 .unwrap_or_else(|| json!([]));
3308 let character = if did.is_empty() {
3313 None
3314 } else {
3315 let card_obj = agent.get("card");
3316 Some(match card_obj {
3317 Some(card) => crate::character::Character::from_card(card),
3318 None => crate::character::Character::from_did(&did),
3319 })
3320 };
3321 let peer_op_claims = agent
3325 .get("card")
3326 .map(op_claims_from_card)
3327 .unwrap_or_default();
3328 let mut row = serde_json::Map::new();
3329 row.insert("handle".into(), json!(handle));
3330 row.insert("did".into(), json!(did));
3331 row.insert("tier".into(), json!(tier));
3332 row.insert("capabilities".into(), capabilities);
3333 row.insert("persona".into(), serde_json::to_value(&character)?);
3334 for (k, v) in peer_op_claims {
3335 row.insert(k, v);
3336 }
3337 peers.push(Value::Object(row));
3338 }
3339
3340 if as_json {
3341 println!("{}", serde_json::to_string(&peers)?);
3342 } else if peers.is_empty() {
3343 println!("no peers pinned (run `wire join <code>` to pair)");
3344 } else {
3345 for p in &peers {
3351 let char_json = &p["persona"];
3352 let (colored_char, plain_len): (String, usize) = match char_json {
3353 serde_json::Value::Null => ("?".to_string(), 1),
3354 v => match serde_json::from_value::<crate::character::Character>(v.clone()) {
3355 Ok(c) => {
3356 let plain = c.short().chars().count() + 1; (c.colored(), plain)
3358 }
3359 Err(_) => ("?".to_string(), 1),
3360 },
3361 };
3362 let pad = 22usize.saturating_sub(plain_len);
3363 println!(
3364 "{}{} {:<20} {:<10} {}",
3365 colored_char,
3366 " ".repeat(pad),
3367 p["handle"].as_str().unwrap_or(""),
3368 p["tier"].as_str().unwrap_or(""),
3369 p["did"].as_str().unwrap_or(""),
3370 );
3371 }
3372 }
3373 Ok(())
3374}
3375
3376fn maybe_warn_peer_attentiveness(peer: &str) {
3386 let state = match config::read_relay_state() {
3387 Ok(s) => s,
3388 Err(_) => return,
3389 };
3390 let p = state.get("peers").and_then(|p| p.get(peer));
3391 let slot_id = match p.and_then(|p| p.get("slot_id")).and_then(Value::as_str) {
3392 Some(s) if !s.is_empty() => s,
3393 _ => return,
3394 };
3395 let slot_token = match p.and_then(|p| p.get("slot_token")).and_then(Value::as_str) {
3396 Some(s) if !s.is_empty() => s,
3397 _ => return,
3398 };
3399 let relay_url = match p.and_then(|p| p.get("relay_url")).and_then(Value::as_str) {
3400 Some(s) if !s.is_empty() => s.to_string(),
3401 _ => match state
3402 .get("self")
3403 .and_then(|s| s.get("relay_url"))
3404 .and_then(Value::as_str)
3405 {
3406 Some(s) if !s.is_empty() => s.to_string(),
3407 _ => return,
3408 },
3409 };
3410 let client = crate::relay_client::RelayClient::new(&relay_url);
3411 let (_count, last_pull) = match client.slot_state(slot_id, slot_token) {
3412 Ok(t) => t,
3413 Err(_) => return,
3414 };
3415 let now = std::time::SystemTime::now()
3416 .duration_since(std::time::UNIX_EPOCH)
3417 .map(|d| d.as_secs())
3418 .unwrap_or(0);
3419 match last_pull {
3420 None => {
3421 eprintln!(
3422 "phyllis: {peer}'s line is silent — relay sees no pulls yet. message will queue, but they may not be listening."
3423 );
3424 }
3425 Some(t) if now.saturating_sub(t) > 300 => {
3426 let mins = now.saturating_sub(t) / 60;
3427 eprintln!(
3428 "phyllis: {peer} hasn't picked up in {mins}m — message will queue, but they may be away."
3429 );
3430 }
3431 _ => {}
3432 }
3433}
3434
3435pub(crate) fn parse_deadline_until(input: &str) -> Result<String> {
3436 let trimmed = input.trim();
3437 if time::OffsetDateTime::parse(trimmed, &time::format_description::well_known::Rfc3339).is_ok()
3438 {
3439 return Ok(trimmed.to_string());
3440 }
3441 let (amount, unit) = trimmed.split_at(trimmed.len().saturating_sub(1));
3442 let n: i64 = amount
3443 .parse()
3444 .with_context(|| format!("deadline must be `30m`, `2h`, `1d`, or RFC3339: {input:?}"))?;
3445 if n <= 0 {
3446 bail!("deadline duration must be positive: {input:?}");
3447 }
3448 let duration = match unit {
3449 "m" => time::Duration::minutes(n),
3450 "h" => time::Duration::hours(n),
3451 "d" => time::Duration::days(n),
3452 _ => bail!("deadline must end in m, h, d, or be RFC3339: {input:?}"),
3453 };
3454 Ok((time::OffsetDateTime::now_utc() + duration)
3455 .format(&time::format_description::well_known::Rfc3339)
3456 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()))
3457}
3458
3459fn cmd_send(
3460 peer: &str,
3461 kind: &str,
3462 body_arg: &str,
3463 deadline: Option<&str>,
3464 no_auto_pair: bool,
3468 as_json: bool,
3469) -> Result<()> {
3470 if !config::is_initialized()? {
3471 bail!("not initialized — run `wire init <handle>` first");
3472 }
3473 let peer_in = crate::agent_card::bare_handle(peer).to_string();
3474 let peer = match resolve_peer_handle(&peer_in) {
3481 Ok(Some(resolved)) if resolved != peer_in => {
3482 eprintln!("wire send: resolved nickname `{peer_in}` → peer `{resolved}`");
3483 resolved
3484 }
3485 Ok(Some(canonical)) => canonical, Ok(None) => peer_in, Err(ResolveError::Ambiguous(candidates)) => bail!(
3488 "nickname `{peer_in}` is ambiguous — matches {} pinned peers: {}. \
3489 Disambiguate by passing the peer handle (one of those listed) instead of the nickname.",
3490 candidates.len(),
3491 candidates.join(", ")
3492 ),
3493 Err(ResolveError::NotFound) => peer_in, };
3495
3496 let peer_is_pinned = config::read_relay_state()
3503 .ok()
3504 .and_then(|s| s.get("peers").and_then(Value::as_object).cloned())
3505 .map(|peers| peers.contains_key(&peer))
3506 .unwrap_or(false);
3507 if !peer_is_pinned && let Some(sister_name) = crate::session::resolve_local_sister(&peer) {
3508 if no_auto_pair {
3509 bail!(
3510 "wire send: `{peer}` resolves to local sister `{sister_name}` but is not pinned, \
3511 and --no-auto-pair was passed. Run `wire dial {peer}` first, \
3512 then re-run send."
3513 );
3514 }
3515 eprintln!(
3516 "wire send: `{peer}` not pinned yet — auto-pairing via local-sister `{sister_name}` first. \
3517 Pass --no-auto-pair to refuse implicit dialing."
3518 );
3519 cmd_add_local_sister(&sister_name, true).map_err(|e| {
3520 anyhow!("wire send: auto-pair to local sister `{sister_name}` failed: {e:#}")
3521 })?;
3522 }
3523
3524 let peer = peer.as_str();
3525 let sk_seed = config::read_private_key()?;
3526 let card = config::read_agent_card()?;
3527 let did = card.get("did").and_then(Value::as_str).unwrap_or("");
3528 let handle = crate::agent_card::display_handle_from_did(did).to_string();
3529 let pk_b64 = card
3530 .get("verify_keys")
3531 .and_then(Value::as_object)
3532 .and_then(|m| m.values().next())
3533 .and_then(|v| v.get("key"))
3534 .and_then(Value::as_str)
3535 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
3536 let pk_bytes = crate::signing::b64decode(pk_b64)?;
3537
3538 let body_value: Value = if body_arg == "-" {
3543 use std::io::Read;
3544 let mut raw = String::new();
3545 std::io::stdin()
3546 .read_to_string(&mut raw)
3547 .with_context(|| "reading body from stdin")?;
3548 serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
3551 } else if let Some(path) = body_arg.strip_prefix('@') {
3552 let raw =
3553 std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
3554 serde_json::from_str(&raw).unwrap_or(Value::String(raw))
3555 } else {
3556 Value::String(body_arg.to_string())
3557 };
3558
3559 let kind_id = parse_kind(kind)?;
3560
3561 let now = time::OffsetDateTime::now_utc()
3562 .format(&time::format_description::well_known::Rfc3339)
3563 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
3564
3565 let mut event = json!({
3566 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
3567 "timestamp": now,
3568 "from": did,
3569 "to": format!("did:wire:{peer}"),
3570 "type": kind,
3571 "kind": kind_id,
3572 "body": body_value,
3573 });
3574 if let Some(deadline) = deadline {
3575 event["time_sensitive_until"] = json!(parse_deadline_until(deadline)?);
3576 }
3577 let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)?;
3578 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
3579
3580 maybe_warn_peer_attentiveness(peer);
3585
3586 let line = serde_json::to_vec(&signed)?;
3591 let outbox = config::append_outbox_record(peer, &line)?;
3592
3593 if as_json {
3594 println!(
3595 "{}",
3596 serde_json::to_string(&json!({
3597 "event_id": event_id,
3598 "status": "queued",
3599 "peer": peer,
3600 "outbox": outbox.to_string_lossy(),
3601 }))?
3602 );
3603 } else {
3604 println!(
3605 "queued event {event_id} → {peer} (outbox: {})",
3606 outbox.display()
3607 );
3608 }
3609 Ok(())
3610}
3611
3612fn parse_kind(s: &str) -> Result<u32> {
3613 if let Ok(n) = s.parse::<u32>() {
3614 return Ok(n);
3615 }
3616 for (id, name) in crate::signing::kinds() {
3617 if *name == s {
3618 return Ok(*id);
3619 }
3620 }
3621 Ok(1)
3623}
3624
3625fn cmd_here(as_json: bool) -> Result<()> {
3631 let initialized = config::is_initialized().unwrap_or(false);
3632
3633 let (self_did, self_handle, self_character) = if initialized {
3635 let card = config::read_agent_card().ok();
3636 let did = card
3637 .as_ref()
3638 .and_then(|c| c.get("did").and_then(Value::as_str))
3639 .unwrap_or("")
3640 .to_string();
3641 let handle = if did.is_empty() {
3642 String::new()
3643 } else {
3644 crate::agent_card::display_handle_from_did(&did).to_string()
3645 };
3646 let character = if did.is_empty() {
3647 None
3648 } else {
3649 Some(crate::character::Character::from_did(&did))
3651 };
3652 (did, handle, character)
3653 } else {
3654 (String::new(), String::new(), None)
3655 };
3656
3657 let cwd = std::env::current_dir()
3658 .map(|p| p.to_string_lossy().into_owned())
3659 .unwrap_or_default();
3660 let wire_home = std::env::var("WIRE_HOME").unwrap_or_default();
3661
3662 let mut sisters: Vec<Value> = Vec::new();
3664 if let Ok(listing) = crate::session::list_local_sessions() {
3665 for group in listing.local.values() {
3666 for s in group {
3667 if s.handle.as_deref() == Some(self_handle.as_str()) {
3668 continue; }
3670 let ch = s.did.as_deref().map(crate::character::Character::from_did);
3671 sisters.push(json!({
3672 "session": s.name,
3673 "handle": s.handle,
3674 "persona": ch,
3675 }));
3676 }
3677 }
3678 }
3679
3680 let mut peers: Vec<Value> = Vec::new();
3682 if initialized
3683 && let Ok(trust) = config::read_trust()
3684 && let Some(agents) = trust.get("agents").and_then(Value::as_object)
3685 {
3686 for (handle, agent) in agents {
3687 if handle == &self_handle {
3688 continue; }
3690 let did = agent.get("did").and_then(Value::as_str).unwrap_or("");
3691 let ch = if did.is_empty() {
3692 None
3693 } else {
3694 Some(crate::character::Character::from_did(did))
3695 };
3696 peers.push(json!({
3697 "handle": handle,
3698 "did": did,
3699 "tier": agent.get("tier").and_then(Value::as_str).unwrap_or("UNKNOWN"),
3700 "persona": ch,
3701 }));
3702 }
3703 }
3704
3705 if as_json {
3706 println!(
3707 "{}",
3708 serde_json::to_string(&json!({
3709 "self": {
3710 "handle": self_handle,
3711 "did": self_did,
3712 "persona": self_character,
3713 "cwd": cwd,
3714 "wire_home": wire_home,
3715 },
3716 "sister_sessions": sisters,
3717 "pinned_peers": peers,
3718 }))?
3719 );
3720 return Ok(());
3721 }
3722
3723 if !initialized {
3725 println!("not initialized — run `wire init <handle>` to bootstrap.");
3726 return Ok(());
3727 }
3728 let glyph = self_character
3729 .as_ref()
3730 .map(crate::character::emoji_with_fallback)
3731 .unwrap_or_else(|| "?".to_string());
3732 let nick = self_character
3733 .as_ref()
3734 .map(|c| c.nickname.clone())
3735 .unwrap_or_default();
3736 println!("you are {glyph} {nick} ({self_handle})");
3737 if !cwd.is_empty() {
3738 println!(" cwd: {cwd}");
3739 }
3740 let render_glyph = |character: &Value| -> String {
3745 let emoji = character
3746 .get("emoji")
3747 .and_then(Value::as_str)
3748 .unwrap_or("?");
3749 let nickname = character
3750 .get("nickname")
3751 .and_then(Value::as_str)
3752 .unwrap_or("?");
3753 if crate::character::terminal_supports_emoji() {
3754 return emoji.to_string();
3755 }
3756 let synth = crate::character::Character {
3759 nickname: nickname.to_string(),
3760 emoji: emoji.to_string(),
3761 palette: crate::character::Palette {
3762 primary_hex: String::new(),
3763 accent_hex: String::new(),
3764 ansi256_primary: 0,
3765 ansi256_accent: 0,
3766 },
3767 };
3768 crate::character::emoji_with_fallback(&synth)
3769 };
3770 if !sisters.is_empty() {
3771 println!();
3772 println!("sister sessions on this machine:");
3773 for s in &sisters {
3774 let session = s["session"].as_str().unwrap_or("?");
3775 let ch_nick = s["persona"]["nickname"].as_str().unwrap_or("?");
3776 let glyph = render_glyph(&s["persona"]);
3777 println!(" {glyph} {ch_nick} ({session})");
3778 }
3779 }
3780 if !peers.is_empty() {
3781 println!();
3782 println!("pinned peers:");
3783 for p in &peers {
3784 let handle = p["handle"].as_str().unwrap_or("?");
3785 let tier = p["tier"].as_str().unwrap_or("");
3786 let ch_nick = p["persona"]["nickname"].as_str().unwrap_or("?");
3787 let glyph = render_glyph(&p["persona"]);
3788 println!(" {glyph} {ch_nick} ({handle}) [{tier}]");
3789 }
3790 }
3791 if sisters.is_empty() && peers.is_empty() {
3792 println!();
3793 println!(
3794 "no neighbors yet — `wire session new` to add a sister, or `wire dial <peer>` to reach out."
3795 );
3796 }
3797 Ok(())
3798}
3799
3800fn cmd_dial(name: &str, message: Option<&str>, as_json: bool) -> Result<()> {
3812 if name.contains('@') {
3813 cmd_add(name, None, false, true)
3819 .map_err(|e| anyhow!("wire dial: federation pair to `{name}` failed: {e:#}"))?;
3820 if let Some(msg) = message {
3821 let bare = name.split('@').next().unwrap_or(name);
3823 cmd_send(bare, "claim", msg, None, false, as_json)?;
3824 }
3825 return Ok(());
3826 }
3827
3828 let resolution = match resolve_name_to_target(name) {
3833 Ok(r) => r,
3834 Err(e) if as_json => {
3835 let pool = known_local_names();
3836 let suggestions = closest_candidates(name, &pool, 3, 3);
3837 println!(
3838 "{}",
3839 serde_json::to_string(&json!({
3840 "name_input": name,
3841 "found": false,
3842 "candidates": suggestions,
3843 "error": format!("{e:#}"),
3844 }))?
3845 );
3846 return Ok(());
3847 }
3848 Err(e) => return Err(e),
3849 };
3850 let mut steps: Vec<Value> = Vec::new();
3851
3852 match &resolution {
3853 DialTarget::PinnedPeer { handle, .. } => {
3854 steps.push(json!({
3855 "step": "resolved",
3856 "kind": "already_pinned",
3857 "handle": handle,
3858 }));
3859 }
3860 DialTarget::LocalSister { session_name, .. } => {
3861 steps.push(json!({
3862 "step": "resolved",
3863 "kind": "local_sister",
3864 "session": session_name,
3865 }));
3866 cmd_add_local_sister(session_name, true).map_err(|e| {
3872 anyhow!("dial: local-sister pair to `{session_name}` failed: {e:#}")
3873 })?;
3874 steps.push(json!({
3875 "step": "paired",
3876 "via": "local_sister",
3877 }));
3878 }
3879 }
3880
3881 let send_handle = match &resolution {
3882 DialTarget::PinnedPeer { handle, .. } => handle.clone(),
3883 DialTarget::LocalSister { handle, .. } => handle.clone(),
3884 };
3885
3886 let send_result = if let Some(msg) = message {
3887 let r = cmd_send(&send_handle, "claim", msg, None, false, true);
3888 match &r {
3889 Ok(()) => steps.push(json!({"step": "sent", "to": send_handle, "kind": "claim"})),
3890 Err(e) => steps.push(json!({"step": "send_failed", "error": format!("{e:#}")})),
3891 }
3892 Some(r)
3893 } else {
3894 None
3895 };
3896
3897 if as_json {
3898 println!(
3899 "{}",
3900 serde_json::to_string(&json!({
3901 "name_input": name,
3902 "resolved_handle": send_handle,
3903 "steps": steps,
3904 }))?
3905 );
3906 } else {
3907 println!("wire dial: resolved `{name}` → handle `{send_handle}`");
3908 for s in &steps {
3909 let step = s.get("step").and_then(Value::as_str).unwrap_or("?");
3910 println!(" - {step}");
3911 }
3912 if message.is_some() {
3913 println!(" (use `wire tail {send_handle}` to read replies)");
3914 }
3915 }
3916 if let Some(Err(e)) = send_result {
3917 return Err(e);
3918 }
3919 Ok(())
3920}
3921
3922fn cmd_whois_local(name: &str, as_json: bool) -> Result<()> {
3928 let resolution = match resolve_name_to_target(name) {
3934 Ok(r) => r,
3935 Err(e) if as_json => {
3936 let pool = known_local_names();
3937 let suggestions = closest_candidates(name, &pool, 3, 3);
3938 println!(
3939 "{}",
3940 serde_json::to_string(&json!({
3941 "name_input": name,
3942 "found": false,
3943 "candidates": suggestions,
3944 "error": format!("{e:#}"),
3945 }))?
3946 );
3947 return Ok(());
3948 }
3949 Err(e) => return Err(e),
3950 };
3951 match resolution {
3952 DialTarget::PinnedPeer {
3953 handle,
3954 did,
3955 nickname,
3956 emoji,
3957 tier,
3958 } => {
3959 let op_claims = config::read_trust()
3963 .ok()
3964 .and_then(|t| {
3965 t.get("agents")
3966 .and_then(Value::as_object)
3967 .and_then(|m| m.get(&handle))
3968 .and_then(|a| a.get("card").cloned())
3969 })
3970 .map(|c| op_claims_from_card(&c))
3971 .unwrap_or_default();
3972
3973 if as_json {
3974 let mut payload = serde_json::Map::new();
3975 payload.insert("kind".into(), json!("pinned_peer"));
3976 payload.insert("handle".into(), json!(handle));
3977 payload.insert("did".into(), json!(did));
3978 payload.insert("nickname".into(), json!(nickname));
3979 payload.insert("emoji".into(), json!(emoji));
3980 payload.insert("tier".into(), json!(tier));
3981 for (k, v) in &op_claims {
3982 payload.insert(k.clone(), v.clone());
3983 }
3984 println!("{}", serde_json::to_string(&payload)?);
3985 } else {
3986 let n = nickname.as_deref().unwrap_or("(no character)");
3987 let e = emoji.as_deref().unwrap_or("?");
3988 println!("{e} {n}");
3989 println!(" handle: {handle}");
3990 println!(" did: {did}");
3991 println!(" tier: {tier}");
3992 if let Some(op_did) = op_claims.get("op_did").and_then(Value::as_str) {
3995 println!(" op_did: {op_did}");
3996 }
3997 println!(" reach: pinned peer (already in trust ring + slot pinned)");
3998 }
3999 }
4000 DialTarget::LocalSister {
4001 session_name,
4002 handle,
4003 did,
4004 nickname,
4005 emoji,
4006 } => {
4007 if as_json {
4008 println!(
4009 "{}",
4010 serde_json::to_string(&json!({
4011 "kind": "local_sister",
4012 "session_name": session_name,
4013 "handle": handle,
4014 "did": did,
4015 "nickname": nickname,
4016 "emoji": emoji,
4017 }))?
4018 );
4019 } else {
4020 let n = nickname.as_deref().unwrap_or("(no character)");
4021 let e = emoji.as_deref().unwrap_or("?");
4022 println!("{e} {n}");
4023 println!(" session: {session_name}");
4024 println!(" handle: {handle}");
4025 println!(
4026 " did: {}",
4027 did.as_deref().unwrap_or("(card unreadable)")
4028 );
4029 println!(" reach: local sister on this machine — `wire dial {n}` pairs us");
4030 }
4031 }
4032 }
4033 Ok(())
4034}
4035
4036pub(crate) enum DialTarget {
4037 PinnedPeer {
4038 handle: String,
4039 did: String,
4040 nickname: Option<String>,
4041 emoji: Option<String>,
4042 tier: String,
4043 },
4044 LocalSister {
4045 session_name: String,
4046 handle: String,
4047 did: Option<String>,
4048 nickname: Option<String>,
4049 emoji: Option<String>,
4050 },
4051}
4052
4053pub(crate) fn resolve_name_to_target(name: &str) -> Result<DialTarget> {
4063 let needle = name.trim();
4064 if needle.is_empty() {
4065 bail!("empty name");
4066 }
4067
4068 if config::is_initialized().unwrap_or(false) {
4071 let trust = config::read_trust().unwrap_or(serde_json::Value::Null);
4072 if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
4073 for (handle_key, agent) in agents {
4074 let did = agent.get("did").and_then(Value::as_str).unwrap_or("");
4075 if did.is_empty() {
4076 continue;
4077 }
4078 let handle = handle_key.clone();
4079 let character = crate::character::Character::from_did(did);
4080 let tier = agent
4081 .get("tier")
4082 .and_then(Value::as_str)
4083 .unwrap_or("UNKNOWN")
4084 .to_string();
4085 let matches = handle.eq_ignore_ascii_case(needle)
4086 || did.eq_ignore_ascii_case(needle)
4087 || character.nickname.eq_ignore_ascii_case(needle);
4088 if matches {
4089 return Ok(DialTarget::PinnedPeer {
4090 handle,
4091 did: did.to_string(),
4092 nickname: Some(character.nickname),
4093 emoji: Some(character.emoji.to_string()),
4094 tier,
4095 });
4096 }
4097 }
4098 }
4099 }
4100
4101 if let Some(session_name) = crate::session::resolve_local_sister(needle) {
4103 let sessions = crate::session::list_sessions().unwrap_or_default();
4104 let s = sessions.iter().find(|s| s.name == session_name);
4105 if let Some(s) = s {
4106 return Ok(DialTarget::LocalSister {
4107 session_name: s.name.clone(),
4108 handle: s.handle.clone().unwrap_or_else(|| s.name.clone()),
4109 did: s.did.clone(),
4110 nickname: s.character.as_ref().map(|c| c.nickname.clone()),
4111 emoji: s.character.as_ref().map(|c| c.emoji.to_string()),
4112 });
4113 }
4114 }
4115
4116 let pool = known_local_names();
4121 let suggestions = closest_candidates(name, &pool, 3, 3);
4122 if suggestions.is_empty() {
4123 bail!(
4124 "no peer matched `{name}`.\n\
4125 Tried: pinned peers (`wire peers`) + local sister sessions \
4126 (`wire session list-local`).\n\
4127 For cross-machine federation: `wire dial <handle>@<relay-domain>`."
4128 );
4129 }
4130 bail!(
4131 "no peer matched `{name}`.\n\
4132 Did you mean: {}?\n\
4133 List all: `wire peers`, `wire session list-local`.",
4134 suggestions
4135 .iter()
4136 .map(|s| format!("`{s}`"))
4137 .collect::<Vec<_>>()
4138 .join(", ")
4139 );
4140}
4141
4142fn cmd_tail(peer: Option<&str>, as_json: bool, limit: usize, oldest: bool) -> Result<()> {
4158 let inbox = config::inbox_dir()?;
4159 if !inbox.exists() {
4160 if !as_json {
4161 eprintln!("no inbox yet — daemon hasn't run, or no events received");
4162 }
4163 return Ok(());
4164 }
4165 let trust = config::read_trust()?;
4166
4167 let entries: Vec<_> = std::fs::read_dir(&inbox)?
4168 .filter_map(|e| e.ok())
4169 .map(|e| e.path())
4170 .filter(|p| {
4171 p.extension().map(|x| x == "jsonl").unwrap_or(false)
4172 && match peer {
4173 Some(want) => p.file_stem().and_then(|s| s.to_str()) == Some(want),
4174 None => true,
4175 }
4176 })
4177 .collect();
4178
4179 let mut events: Vec<(String, usize, Value)> = Vec::new();
4185 for path in &entries {
4186 let body = std::fs::read_to_string(path)?;
4187 for (idx, line) in body.lines().enumerate() {
4188 let event: Value = match serde_json::from_str(line) {
4189 Ok(v) => v,
4190 Err(_) => continue,
4191 };
4192 let ts = event
4193 .get("timestamp")
4194 .and_then(Value::as_str)
4195 .unwrap_or("")
4196 .to_string();
4197 events.push((ts, idx, event));
4198 }
4199 }
4200 events.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
4201
4202 let total = events.len();
4204 let window: &[(String, usize, Value)] = if limit == 0 {
4205 &events[..]
4206 } else if oldest {
4207 &events[..limit.min(total)]
4208 } else {
4209 let start = total.saturating_sub(limit);
4210 &events[start..]
4211 };
4212
4213 for (_, _, event) in window {
4214 let verified = verify_message_v31(event, &trust).is_ok();
4215 if as_json {
4216 let mut event_with_meta = event.clone();
4217 if let Some(obj) = event_with_meta.as_object_mut() {
4218 obj.insert("verified".into(), json!(verified));
4219 }
4220 println!("{}", serde_json::to_string(&event_with_meta)?);
4221 } else {
4222 let ts = event
4223 .get("timestamp")
4224 .and_then(Value::as_str)
4225 .unwrap_or("?");
4226 let from = event.get("from").and_then(Value::as_str).unwrap_or("?");
4227 let kind = event.get("kind").and_then(Value::as_u64).unwrap_or(0);
4228 let kind_name = event.get("type").and_then(Value::as_str).unwrap_or("?");
4229 let summary = event
4230 .get("body")
4231 .map(|b| match b {
4232 Value::String(s) => s.clone(),
4233 _ => b.to_string(),
4234 })
4235 .unwrap_or_default();
4236 let mark = if verified { "✓" } else { "✗" };
4237 let deadline = event
4238 .get("time_sensitive_until")
4239 .and_then(Value::as_str)
4240 .map(|d| format!(" deadline: {d}"))
4241 .unwrap_or_default();
4242 println!("[{ts} {from} kind={kind} {kind_name}{deadline}] {summary} | sig {mark}");
4243 }
4244 }
4245 Ok(())
4246}
4247
4248fn monitor_is_noise_kind(kind: &str) -> bool {
4254 matches!(kind, "pair_drop" | "pair_drop_ack" | "heartbeat")
4255}
4256
4257fn resolve_persona(peer_handle: &str) -> Option<crate::character::Character> {
4261 let trust = config::read_trust().ok()?;
4262 let agent = trust.get("agents").and_then(|a| a.get(peer_handle))?;
4263 if let Some(card) = agent.get("card") {
4264 Some(crate::character::Character::from_card(card))
4265 } else {
4266 let did = agent.get("did").and_then(Value::as_str)?;
4267 Some(crate::character::Character::from_did(did))
4268 }
4269}
4270
4271fn persona_label(peer_handle: &str) -> String {
4273 match resolve_persona(peer_handle) {
4274 Some(ch) => format!("{} {}", ch.emoji, ch.nickname),
4275 None => peer_handle.to_string(),
4276 }
4277}
4278
4279fn monitor_render(e: &crate::inbox_watch::InboxEvent, as_json: bool) -> Result<String> {
4287 if as_json {
4288 Ok(serde_json::to_string(e)?)
4289 } else {
4290 let eid_short: String = e.event_id.chars().take(12).collect();
4291 let body = e.body_preview.replace('\n', " ");
4292 let ts: String = e.timestamp.chars().take(19).collect();
4293 Ok(format!("[{ts}] {}/{} ({eid_short}) {body}", e.peer, e.kind))
4294 }
4295}
4296
4297fn cmd_monitor(
4313 peer_filter: Option<&str>,
4314 as_json: bool,
4315 include_handshake: bool,
4316 interval_ms: u64,
4317 replay: usize,
4318) -> Result<()> {
4319 let inbox_dir = config::inbox_dir()?;
4320 if !inbox_dir.exists() && !as_json {
4321 eprintln!("wire monitor: inbox dir {inbox_dir:?} missing — has the daemon ever run?");
4322 }
4323 crate::session::warn_on_identity_collision(std::process::id(), "monitor");
4328 if replay > 0 && inbox_dir.exists() {
4334 let mut all: Vec<crate::inbox_watch::InboxEvent> = Vec::new();
4335 for entry in std::fs::read_dir(&inbox_dir)?.flatten() {
4336 let path = entry.path();
4337 if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
4338 continue;
4339 }
4340 let peer = match path.file_stem().and_then(|s| s.to_str()) {
4341 Some(s) => s.to_string(),
4342 None => continue,
4343 };
4344 if let Some(filter) = peer_filter
4345 && peer != filter
4346 {
4347 continue;
4348 }
4349 let body = std::fs::read_to_string(&path).unwrap_or_default();
4350 for line in body.lines() {
4351 let line = line.trim();
4352 if line.is_empty() {
4353 continue;
4354 }
4355 let signed: Value = match serde_json::from_str(line) {
4356 Ok(v) => v,
4357 Err(_) => continue,
4358 };
4359 let ev = crate::inbox_watch::InboxEvent::from_signed(
4360 &peer, signed, true,
4361 );
4362 if !include_handshake && monitor_is_noise_kind(&ev.kind) {
4363 continue;
4364 }
4365 all.push(ev);
4366 }
4367 }
4368 all.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
4371 let start = all.len().saturating_sub(replay);
4372 for ev in &all[start..] {
4373 println!("{}", monitor_render(ev, as_json)?);
4374 }
4375 use std::io::Write;
4376 std::io::stdout().flush().ok();
4377 }
4378
4379 let mut w = crate::inbox_watch::InboxWatcher::from_head()?;
4382 let sleep_dur = std::time::Duration::from_millis(interval_ms.max(50));
4383
4384 loop {
4385 let events = match w.poll() {
4392 Ok(evs) => evs,
4393 Err(e) => {
4394 eprintln!("wire monitor: poll error (continuing to watch): {e:#}");
4395 std::thread::sleep(sleep_dur);
4396 continue;
4397 }
4398 };
4399 let mut wrote = false;
4400 for ev in events {
4401 if let Some(filter) = peer_filter
4402 && ev.peer != filter
4403 {
4404 continue;
4405 }
4406 if !include_handshake && monitor_is_noise_kind(&ev.kind) {
4407 continue;
4408 }
4409 println!("{}", monitor_render(&ev, as_json)?);
4410 wrote = true;
4411 }
4412 if wrote {
4413 use std::io::Write;
4414 std::io::stdout().flush().ok();
4415 }
4416 std::thread::sleep(sleep_dur);
4417 }
4418}
4419
4420#[cfg(test)]
4421mod tier_tests {
4422 use super::*;
4423 use serde_json::json;
4424
4425 fn trust_with(handle: &str, tier: &str) -> Value {
4426 json!({
4427 "version": 1,
4428 "agents": {
4429 handle: {
4430 "tier": tier,
4431 "did": format!("did:wire:{handle}"),
4432 "card": {"capabilities": ["wire/v3.1"]}
4433 }
4434 }
4435 })
4436 }
4437
4438 #[test]
4439 fn pending_ack_when_verified_but_no_slot_token() {
4440 let trust = trust_with("willard", "VERIFIED");
4444 let relay_state = json!({
4445 "peers": {
4446 "willard": {
4447 "relay_url": "https://relay",
4448 "slot_id": "abc",
4449 "slot_token": "",
4450 }
4451 }
4452 });
4453 assert_eq!(
4454 effective_peer_tier(&trust, &relay_state, "willard"),
4455 "PENDING_ACK"
4456 );
4457 }
4458
4459 #[test]
4460 fn verified_when_slot_token_present() {
4461 let trust = trust_with("willard", "VERIFIED");
4462 let relay_state = json!({
4463 "peers": {
4464 "willard": {
4465 "relay_url": "https://relay",
4466 "slot_id": "abc",
4467 "slot_token": "tok123",
4468 }
4469 }
4470 });
4471 assert_eq!(
4472 effective_peer_tier(&trust, &relay_state, "willard"),
4473 "VERIFIED"
4474 );
4475 }
4476
4477 #[test]
4478 fn raw_tier_passes_through_for_non_verified() {
4479 let trust = trust_with("willard", "UNTRUSTED");
4482 let relay_state = json!({
4483 "peers": {"willard": {"slot_token": ""}}
4484 });
4485 assert_eq!(
4486 effective_peer_tier(&trust, &relay_state, "willard"),
4487 "UNTRUSTED"
4488 );
4489 }
4490
4491 #[test]
4492 fn pending_ack_when_relay_state_missing_peer() {
4493 let trust = trust_with("willard", "VERIFIED");
4497 let relay_state = json!({"peers": {}});
4498 assert_eq!(
4499 effective_peer_tier(&trust, &relay_state, "willard"),
4500 "PENDING_ACK"
4501 );
4502 }
4503}
4504
4505#[cfg(test)]
4506mod monitor_tests {
4507 use super::*;
4508 use crate::inbox_watch::InboxEvent;
4509 use serde_json::Value;
4510
4511 fn ev(peer: &str, kind: &str, body: &str) -> InboxEvent {
4512 InboxEvent {
4513 peer: peer.to_string(),
4514 event_id: "abcd1234567890ef".to_string(),
4515 kind: kind.to_string(),
4516 body_preview: body.to_string(),
4517 verified: true,
4518 timestamp: "2026-05-15T23:14:07.123456Z".to_string(),
4519 raw: Value::Null,
4520 }
4521 }
4522
4523 #[test]
4524 fn monitor_filter_drops_handshake_kinds_by_default() {
4525 assert!(monitor_is_noise_kind("pair_drop"));
4530 assert!(monitor_is_noise_kind("pair_drop_ack"));
4531 assert!(monitor_is_noise_kind("heartbeat"));
4532
4533 assert!(!monitor_is_noise_kind("claim"));
4535 assert!(!monitor_is_noise_kind("decision"));
4536 assert!(!monitor_is_noise_kind("ack"));
4537 assert!(!monitor_is_noise_kind("request"));
4538 assert!(!monitor_is_noise_kind("note"));
4539 assert!(!monitor_is_noise_kind("future_kind_we_dont_know"));
4543 }
4544
4545 #[test]
4546 fn monitor_render_plain_is_one_short_line() {
4547 let e = ev("willard", "claim", "real v8 train shipped 1350 steps");
4548 let line = monitor_render(&e, false).unwrap();
4549 assert!(!line.contains('\n'), "render must be one line: {line}");
4551 assert!(line.contains("willard"));
4553 assert!(line.contains("claim"));
4554 assert!(line.contains("real v8 train"));
4555 assert!(line.contains("abcd12345678"));
4557 assert!(
4558 !line.contains("abcd1234567890ef"),
4559 "should truncate full id"
4560 );
4561 assert!(line.contains("2026-05-15T23:14:07"));
4563 }
4564
4565 #[test]
4566 fn monitor_render_strips_newlines_from_body() {
4567 let e = ev("spark", "claim", "line one\nline two\nline three");
4572 let line = monitor_render(&e, false).unwrap();
4573 assert!(!line.contains('\n'), "newlines must be stripped: {line}");
4574 assert!(line.contains("line one line two line three"));
4575 }
4576
4577 #[test]
4578 fn monitor_render_json_is_valid_jsonl() {
4579 let e = ev("spark", "claim", "hi");
4580 let line = monitor_render(&e, true).unwrap();
4581 assert!(!line.contains('\n'));
4582 let parsed: Value = serde_json::from_str(&line).expect("valid JSONL");
4583 assert_eq!(parsed["peer"], "spark");
4584 assert_eq!(parsed["kind"], "claim");
4585 assert_eq!(parsed["body_preview"], "hi");
4586 }
4587
4588 #[test]
4589 fn monitor_does_not_drop_on_verified_null() {
4590 let mut e = ev("spark", "claim", "from disk with verified=null");
4601 e.verified = false; let line = monitor_render(&e, false).unwrap();
4603 assert!(line.contains("from disk with verified=null"));
4604 assert!(!monitor_is_noise_kind("claim"));
4606 }
4607}
4608
4609fn cmd_verify(path: &str, as_json: bool) -> Result<()> {
4612 let body = if path == "-" {
4613 let mut buf = String::new();
4614 use std::io::Read;
4615 std::io::stdin().read_to_string(&mut buf)?;
4616 buf
4617 } else {
4618 std::fs::read_to_string(path).with_context(|| format!("reading {path}"))?
4619 };
4620 let event: Value = serde_json::from_str(&body)?;
4621 let trust = config::read_trust()?;
4622 match verify_message_v31(&event, &trust) {
4623 Ok(()) => {
4624 if as_json {
4625 println!("{}", serde_json::to_string(&json!({"verified": true}))?);
4626 } else {
4627 println!("verified ✓");
4628 }
4629 Ok(())
4630 }
4631 Err(e) => {
4632 let reason = e.to_string();
4633 if as_json {
4634 println!(
4635 "{}",
4636 serde_json::to_string(&json!({"verified": false, "reason": reason}))?
4637 );
4638 } else {
4639 eprintln!("FAILED: {reason}");
4640 }
4641 std::process::exit(1);
4642 }
4643 }
4644}
4645
4646fn cmd_mcp() -> Result<()> {
4649 crate::mcp::run()
4650}
4651
4652fn cmd_relay_server(bind: &str, local_only: bool, uds: Option<&std::path::Path>) -> Result<()> {
4653 if let Some(socket_path) = uds {
4658 let base = if let Ok(home) = std::env::var("WIRE_HOME") {
4659 std::path::PathBuf::from(home)
4660 .join("state")
4661 .join("wire-relay")
4662 .join("uds")
4663 } else {
4664 dirs::state_dir()
4665 .or_else(dirs::data_local_dir)
4666 .ok_or_else(|| anyhow::anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?
4667 .join("wire-relay")
4668 .join("uds")
4669 };
4670 let runtime = tokio::runtime::Builder::new_multi_thread()
4671 .enable_all()
4672 .build()?;
4673 return runtime.block_on(crate::relay_server::serve_uds(
4674 socket_path.to_path_buf(),
4675 base,
4676 ));
4677 }
4678 if local_only {
4682 validate_loopback_bind(bind)?;
4683 }
4684 let base = if let Ok(home) = std::env::var("WIRE_HOME") {
4690 std::path::PathBuf::from(home)
4691 .join("state")
4692 .join("wire-relay")
4693 } else {
4694 dirs::state_dir()
4695 .or_else(dirs::data_local_dir)
4696 .ok_or_else(|| anyhow::anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?
4697 .join("wire-relay")
4698 };
4699 let state_dir = if local_only { base.join("local") } else { base };
4700 let runtime = tokio::runtime::Builder::new_multi_thread()
4701 .enable_all()
4702 .build()?;
4703 runtime.block_on(crate::relay_server::serve_with_mode(
4704 bind,
4705 state_dir,
4706 crate::relay_server::ServerMode { local_only },
4707 ))
4708}
4709
4710fn validate_loopback_bind(bind: &str) -> Result<()> {
4728 let host = if let Some(stripped) = bind.strip_prefix('[') {
4730 let close = stripped
4731 .find(']')
4732 .ok_or_else(|| anyhow::anyhow!("malformed IPv6 bind {bind:?}"))?;
4733 stripped[..close].to_string()
4734 } else {
4735 bind.rsplit_once(':')
4736 .map(|(h, _)| h.to_string())
4737 .unwrap_or_else(|| bind.to_string())
4738 };
4739 use std::net::{IpAddr, ToSocketAddrs};
4740 let probe = format!("{host}:0");
4741 let resolved: Vec<_> = probe
4742 .to_socket_addrs()
4743 .with_context(|| format!("resolving bind host {host:?}"))?
4744 .collect();
4745 if resolved.is_empty() {
4746 bail!("--local-only: bind host {host:?} resolved to no addresses");
4747 }
4748 for addr in &resolved {
4749 let ip = addr.ip();
4750 let is_acceptable = match ip {
4751 IpAddr::V4(v4) => {
4752 v4.is_loopback() || v4.is_private() || {
4753 let octets = v4.octets();
4755 octets[0] == 100 && (64..=127).contains(&octets[1])
4756 }
4757 }
4758 IpAddr::V6(v6) => v6.is_loopback(), };
4760 if !is_acceptable {
4761 bail!(
4762 "--local-only refuses non-private bind: {host:?} resolves to {ip} \
4763 which is not loopback (127/8, ::1), RFC 1918 private \
4764 (10/8, 172.16/12, 192.168/16), or RFC 6598 CGNAT/Tailscale \
4765 (100.64.0.0/10). Remove --local-only to bind publicly."
4766 );
4767 }
4768 }
4769 Ok(())
4770}
4771
4772fn parse_scope(s: &str) -> Result<crate::endpoints::EndpointScope> {
4775 use crate::endpoints::EndpointScope;
4776 match s.to_lowercase().as_str() {
4777 "federation" | "fed" => Ok(EndpointScope::Federation),
4778 "local" => Ok(EndpointScope::Local),
4779 "lan" => Ok(EndpointScope::Lan),
4780 "uds" => Ok(EndpointScope::Uds),
4781 other => bail!("unknown --scope `{other}` (expected federation|local|lan|uds)"),
4782 }
4783}
4784
4785fn cmd_bind_relay(
4791 url: &str,
4792 scope: Option<&str>,
4793 replace: bool,
4794 migrate_pinned: bool,
4795 as_json: bool,
4796) -> Result<()> {
4797 use crate::endpoints::{Endpoint, self_endpoints};
4798
4799 if !config::is_initialized()? {
4800 bail!("not initialized — run `wire init <handle>` first");
4801 }
4802 let card = config::read_agent_card()?;
4803 let did = card.get("did").and_then(Value::as_str).unwrap_or("");
4804 let handle = crate::agent_card::display_handle_from_did(did).to_string();
4805
4806 let normalized_raw = url.trim_end_matches('/');
4807 let normalized_owned = strip_relay_url_userinfo(normalized_raw);
4811 let normalized = normalized_owned.as_str();
4812 assert_relay_url_clean_for_publish(normalized)?;
4816 let new_scope = match scope {
4817 Some(s) => parse_scope(s)?,
4818 None => crate::endpoints::infer_scope_from_url(normalized),
4819 };
4820
4821 let existing = config::read_relay_state().unwrap_or_else(|_| json!({}));
4822 let pinned: Vec<String> = existing
4823 .get("peers")
4824 .and_then(|p| p.as_object())
4825 .map(|o| o.keys().cloned().collect())
4826 .unwrap_or_default();
4827
4828 let existing_eps = self_endpoints(&existing);
4829 let is_rebind_same = existing_eps.iter().any(|e| e.relay_url == normalized);
4830
4831 let destructive = replace || is_rebind_same;
4838 if destructive && !pinned.is_empty() && !migrate_pinned {
4839 let list = pinned.join(", ");
4840 let why = if replace {
4841 "`--replace` drops your other slot(s)"
4842 } else {
4843 "re-binding the same relay rotates its slot"
4844 };
4845 bail!(
4846 "bind-relay would black-hole {n} pinned peer(s): {list}. {why}; they are \
4847 pinned to your CURRENT slot and would keep pushing to a slot you no longer \
4848 read.\n\n\
4849 SAFE PATHS:\n\
4850 • Default (omit `--replace`) ADDITIVELY binds a NEW relay, keeping existing \
4851 slots — no black-hole.\n\
4852 • `wire rotate-slot` — same-relay rotation that emits wire_close to peers.\n\
4853 • `wire bind-relay {url} --migrate-pinned` — proceed anyway; re-pair each \
4854 peer out-of-band.\n\n\
4855 Issue #7 (silent black-hole on relay change) caught this.",
4856 n = pinned.len(),
4857 );
4858 }
4859
4860 let client = crate::relay_client::RelayClient::new(normalized);
4861 client.check_healthz()?;
4862 let alloc = client.allocate_slot(Some(&handle))?;
4863
4864 if destructive && !pinned.is_empty() {
4865 eprintln!(
4866 "wire bind-relay: {mode} with {n} pinned peer(s) — they will black-hole \
4867 until they re-pin: {peers}",
4868 mode = if replace { "replacing" } else { "rotating" },
4869 n = pinned.len(),
4870 peers = pinned.join(", "),
4871 );
4872 }
4873
4874 let mut state = existing;
4878 if replace {
4879 state["self"] = Value::Null;
4880 }
4881 crate::endpoints::upsert_self_endpoint(
4882 &mut state,
4883 Endpoint {
4884 relay_url: normalized.to_string(),
4885 slot_id: alloc.slot_id.clone(),
4886 slot_token: alloc.slot_token.clone(),
4887 scope: new_scope,
4888 },
4889 );
4890 config::write_relay_state(&state)?;
4891 let eps = self_endpoints(&state);
4892
4893 let scope_str = format!("{new_scope:?}").to_lowercase();
4894 if as_json {
4895 println!(
4896 "{}",
4897 serde_json::to_string(&json!({
4898 "relay_url": normalized,
4899 "slot_id": alloc.slot_id,
4900 "scope": scope_str,
4901 "endpoints": eps.len(),
4902 "additive": !replace,
4903 "slot_token_present": true,
4904 }))?
4905 );
4906 } else {
4907 println!(
4908 "bound {scope_str} slot on {normalized} (slot {})",
4909 alloc.slot_id
4910 );
4911 println!(
4912 "self now has {n} endpoint(s): {list}",
4913 n = eps.len(),
4914 list = eps
4915 .iter()
4916 .map(|e| format!("{}({:?})", e.relay_url, e.scope))
4917 .collect::<Vec<_>>()
4918 .join(", "),
4919 );
4920 }
4921 Ok(())
4922}
4923
4924fn cmd_add_peer_slot(
4927 handle: &str,
4928 url: &str,
4929 slot_id: &str,
4930 slot_token: &str,
4931 as_json: bool,
4932) -> Result<()> {
4933 use crate::endpoints::{Endpoint, infer_scope_from_url, pin_peer_endpoints};
4934 let mut state = config::read_relay_state()?;
4935
4936 let new_ep = Endpoint {
4943 relay_url: url.to_string(),
4944 slot_id: slot_id.to_string(),
4945 slot_token: slot_token.to_string(),
4946 scope: infer_scope_from_url(url),
4947 };
4948 let mut endpoints: Vec<Endpoint> = state
4949 .get("peers")
4950 .and_then(|p| p.get(handle))
4951 .and_then(|e| e.get("endpoints"))
4952 .and_then(|a| serde_json::from_value::<Vec<Endpoint>>(a.clone()).ok())
4953 .unwrap_or_default();
4954 if endpoints.is_empty()
4956 && let Some(peer) = state.get("peers").and_then(|p| p.get(handle))
4957 && let (Some(ru), Some(si), Some(st)) = (
4958 peer.get("relay_url").and_then(Value::as_str),
4959 peer.get("slot_id").and_then(Value::as_str),
4960 peer.get("slot_token").and_then(Value::as_str),
4961 )
4962 {
4963 endpoints.push(Endpoint {
4964 relay_url: ru.to_string(),
4965 slot_id: si.to_string(),
4966 slot_token: st.to_string(),
4967 scope: infer_scope_from_url(ru),
4968 });
4969 }
4970 if let Some(existing) = endpoints
4972 .iter_mut()
4973 .find(|e| e.relay_url == new_ep.relay_url)
4974 {
4975 *existing = new_ep;
4976 } else {
4977 endpoints.push(new_ep);
4978 }
4979 let n = endpoints.len();
4980 pin_peer_endpoints(&mut state, handle, &endpoints)?;
4981 config::write_relay_state(&state)?;
4982 if as_json {
4983 println!(
4984 "{}",
4985 serde_json::to_string(&json!({
4986 "handle": handle,
4987 "relay_url": url,
4988 "slot_id": slot_id,
4989 "added": true,
4990 "endpoint_count": n,
4991 }))?
4992 );
4993 } else {
4994 println!(
4995 "pinned peer slot for {handle} at {url} ({slot_id}) — peer now has {n} endpoint(s)"
4996 );
4997 }
4998 Ok(())
4999}
5000
5001fn cmd_push(peer_filter: Option<&str>, as_json: bool) -> Result<()> {
5004 let mut state = config::read_relay_state()?;
5005 let peers = state["peers"].as_object().cloned().unwrap_or_default();
5006 if peers.is_empty() {
5007 bail!(
5008 "no peer slots pinned — run `wire add-peer-slot <handle> <url> <slot_id> <token>` first"
5009 );
5010 }
5011 let outbox_dir = config::outbox_dir()?;
5012 if outbox_dir.exists() {
5017 let pinned: std::collections::HashSet<String> = peers.keys().cloned().collect();
5018 for entry in std::fs::read_dir(&outbox_dir)?.flatten() {
5019 let path = entry.path();
5020 if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
5021 continue;
5022 }
5023 let stem = match path.file_stem().and_then(|s| s.to_str()) {
5024 Some(s) => s.to_string(),
5025 None => continue,
5026 };
5027 if pinned.contains(&stem) {
5028 continue;
5029 }
5030 let bare = crate::agent_card::bare_handle(&stem);
5033 if pinned.contains(bare) {
5034 eprintln!(
5035 "wire push: WARN stale outbox file `{}.jsonl` not enumerated (pinned peer is `{bare}`). \
5036 Merge with: `cat {} >> {}` then delete the FQDN file.",
5037 stem,
5038 path.display(),
5039 outbox_dir.join(format!("{bare}.jsonl")).display(),
5040 );
5041 }
5042 }
5043 }
5044 if !outbox_dir.exists() {
5045 if as_json {
5046 println!(
5047 "{}",
5048 serde_json::to_string(&json!({"pushed": [], "skipped": []}))?
5049 );
5050 } else {
5051 println!("phyllis: nothing to dial out — write a message first with `wire send`");
5052 }
5053 return Ok(());
5054 }
5055
5056 let mut pushed = Vec::new();
5057 let mut skipped = Vec::new();
5058
5059 let mut rotated_this_push: std::collections::HashSet<String> = std::collections::HashSet::new();
5064 let mut state_dirty = false;
5067
5068 for (peer_handle, _) in peers.iter() {
5074 if let Some(want) = peer_filter
5075 && peer_handle != want
5076 {
5077 continue;
5078 }
5079 let outbox = outbox_dir.join(format!("{peer_handle}.jsonl"));
5080 if !outbox.exists() {
5081 continue;
5082 }
5083 let mut ordered_endpoints =
5084 crate::endpoints::peer_endpoints_in_priority_order(&state, peer_handle);
5085 if ordered_endpoints.is_empty() {
5086 for line in std::fs::read_to_string(&outbox).unwrap_or_default().lines() {
5090 let event: Value = match serde_json::from_str(line) {
5091 Ok(v) => v,
5092 Err(_) => continue,
5093 };
5094 let event_id = event
5095 .get("event_id")
5096 .and_then(Value::as_str)
5097 .unwrap_or("")
5098 .to_string();
5099 skipped.push(json!({
5100 "peer": peer_handle,
5101 "event_id": event_id,
5102 "reason": "no reachable endpoint pinned for peer",
5103 }));
5104 }
5105 continue;
5106 }
5107 let body = std::fs::read_to_string(&outbox)?;
5108 for line in body.lines() {
5109 let event: Value = match serde_json::from_str(line) {
5110 Ok(v) => v,
5111 Err(_) => continue,
5112 };
5113 let event_id = event
5114 .get("event_id")
5115 .and_then(Value::as_str)
5116 .unwrap_or("")
5117 .to_string();
5118
5119 let last_err: std::cell::RefCell<Option<String>> = std::cell::RefCell::new(None);
5128 match crate::relay_client::try_post_event_with_failover(
5129 &ordered_endpoints,
5130 &event,
5131 |endpoint, ev| {
5132 let client = crate::relay_client::RelayClient::new(&endpoint.relay_url);
5133 match client.post_event(&endpoint.slot_id, &endpoint.slot_token, ev) {
5134 Ok(resp) => Ok(resp),
5135 Err(e) => {
5136 *last_err.borrow_mut() =
5137 Some(crate::relay_client::format_transport_error(&e));
5138 Err(e)
5139 }
5140 }
5141 },
5142 ) {
5143 Ok((endpoint, resp)) => {
5144 if resp.status == "duplicate" {
5145 skipped.push(json!({
5146 "peer": peer_handle,
5147 "event_id": event_id,
5148 "reason": "duplicate",
5149 "endpoint": endpoint.relay_url,
5150 "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
5151 }));
5152 } else {
5153 pushed.push(json!({
5154 "peer": peer_handle,
5155 "event_id": event_id,
5156 "endpoint": endpoint.relay_url,
5157 "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
5158 }));
5159 }
5160 }
5161 Err(_) => {
5162 let last_err_text = last_err.borrow().clone().unwrap_or_default();
5172 let mut delivered_via_retry: Option<(crate::endpoints::Endpoint, _)> = None;
5173 match try_reresolve_peer_on_slot_4xx(
5174 &mut state,
5175 peer_handle,
5176 &last_err_text,
5177 &rotated_this_push,
5178 ) {
5179 Ok(true) => {
5180 rotated_this_push.insert(peer_handle.clone());
5182 state_dirty = true;
5183 ordered_endpoints = crate::endpoints::peer_endpoints_in_priority_order(
5188 &state,
5189 peer_handle,
5190 );
5191 *last_err.borrow_mut() = None;
5192 if let Ok((endpoint, resp)) =
5193 crate::relay_client::try_post_event_with_failover(
5194 &ordered_endpoints,
5195 &event,
5196 |endpoint, ev| {
5197 let client = crate::relay_client::RelayClient::new(
5198 &endpoint.relay_url,
5199 );
5200 match client.post_event(
5201 &endpoint.slot_id,
5202 &endpoint.slot_token,
5203 ev,
5204 ) {
5205 Ok(resp) => Ok(resp),
5206 Err(e) => {
5207 *last_err.borrow_mut() = Some(
5208 crate::relay_client::format_transport_error(&e),
5209 );
5210 Err(e)
5211 }
5212 }
5213 },
5214 )
5215 {
5216 delivered_via_retry = Some((endpoint, resp));
5217 }
5218 }
5219 Ok(false) => {
5220 }
5224 Err(e) => {
5225 *last_err.borrow_mut() = Some(format!(
5230 "{}; re-resolve also failed: {e:#}",
5231 last_err.borrow().clone().unwrap_or_default()
5232 ));
5233 rotated_this_push.insert(peer_handle.clone());
5235 }
5236 }
5237 if let Some((endpoint, resp)) = delivered_via_retry {
5238 if resp.status == "duplicate" {
5239 skipped.push(json!({
5240 "peer": peer_handle,
5241 "event_id": event_id,
5242 "reason": "duplicate",
5243 "endpoint": endpoint.relay_url,
5244 "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
5245 "via": "slot_reresolve_retry",
5246 }));
5247 } else {
5248 pushed.push(json!({
5249 "peer": peer_handle,
5250 "event_id": event_id,
5251 "endpoint": endpoint.relay_url,
5252 "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
5253 "via": "slot_reresolve_retry",
5254 }));
5255 }
5256 } else {
5257 skipped.push(json!({
5262 "peer": peer_handle,
5263 "event_id": event_id,
5264 "reason": last_err
5265 .borrow()
5266 .clone()
5267 .unwrap_or_else(|| "all endpoints failed".to_string()),
5268 }));
5269 }
5270 }
5271 }
5272 }
5273 }
5274
5275 if state_dirty && let Err(e) = config::write_relay_state(&state) {
5280 eprintln!(
5281 "wire push: WARN failed to persist rotated peer slots: {e:#}. \
5282 Slot rotation will be re-attempted on next push."
5283 );
5284 }
5285
5286 if as_json {
5287 println!(
5288 "{}",
5289 serde_json::to_string(&json!({"pushed": pushed, "skipped": skipped}))?
5290 );
5291 } else {
5292 println!(
5293 "pushed {} event(s); skipped {} ({})",
5294 pushed.len(),
5295 skipped.len(),
5296 if skipped.is_empty() {
5297 "none"
5298 } else {
5299 "see --json for detail"
5300 }
5301 );
5302 }
5303 Ok(())
5304}
5305
5306fn cmd_pull(as_json: bool) -> Result<()> {
5309 let state = config::read_relay_state()?;
5310 let self_state = state.get("self").cloned().unwrap_or(Value::Null);
5311 if self_state.is_null() {
5312 bail!("self slot not bound — run `wire bind-relay <url>` first");
5313 }
5314
5315 let endpoints = crate::endpoints::self_endpoints(&state);
5324 if endpoints.is_empty() {
5325 bail!("self.relay_url / slot_id / slot_token missing in relay_state.json");
5326 }
5327
5328 let inbox_dir = config::inbox_dir()?;
5329 config::ensure_dirs()?;
5330
5331 let mut total_seen = 0usize;
5332 let mut all_written: Vec<Value> = Vec::new();
5333 let mut all_rejected: Vec<Value> = Vec::new();
5334 let mut all_blocked = false;
5335 let mut all_advance_cursor_to: Option<String> = None;
5336
5337 for endpoint in &endpoints {
5338 let cursor_key = endpoint_cursor_key(endpoint.scope);
5339 let last_event_id = self_state
5340 .get(&cursor_key)
5341 .and_then(Value::as_str)
5342 .map(str::to_string);
5343 let client = crate::relay_client::RelayClient::new(&endpoint.relay_url);
5344 let events = match client.list_events(
5345 &endpoint.slot_id,
5346 &endpoint.slot_token,
5347 last_event_id.as_deref(),
5348 Some(1000),
5349 ) {
5350 Ok(ev) => ev,
5351 Err(e) => {
5352 eprintln!(
5356 "wire pull: endpoint {} ({:?}) errored: {}; continuing",
5357 endpoint.relay_url,
5358 endpoint.scope,
5359 crate::relay_client::format_transport_error(&e),
5360 );
5361 continue;
5362 }
5363 };
5364 total_seen += events.len();
5365 let result = crate::pull::process_events(&events, last_event_id.clone(), &inbox_dir)?;
5366 all_written.extend(result.written.iter().cloned());
5367 all_rejected.extend(result.rejected.iter().cloned());
5368 if result.blocked {
5369 all_blocked = true;
5370 }
5371 if let Some(eid) = result.advance_cursor_to.clone() {
5374 if endpoint.scope == crate::endpoints::EndpointScope::Federation {
5375 all_advance_cursor_to = Some(eid.clone());
5376 }
5377 let key = cursor_key.clone();
5378 config::update_relay_state(|state| {
5379 if let Some(self_obj) = state.get_mut("self").and_then(Value::as_object_mut) {
5380 self_obj.insert(key, Value::String(eid));
5381 }
5382 Ok(())
5383 })?;
5384 }
5385 }
5386
5387 let result = crate::pull::PullResult {
5392 written: all_written,
5393 rejected: all_rejected,
5394 blocked: all_blocked,
5395 advance_cursor_to: all_advance_cursor_to,
5396 };
5397 let events_len = total_seen;
5398
5399 if as_json {
5403 println!(
5404 "{}",
5405 serde_json::to_string(&json!({
5406 "written": result.written,
5407 "rejected": result.rejected,
5408 "total_seen": events_len,
5409 "cursor_blocked": result.blocked,
5410 "cursor_advanced_to": result.advance_cursor_to,
5411 }))?
5412 );
5413 } else {
5414 let blocking = result
5415 .rejected
5416 .iter()
5417 .filter(|r| r.get("blocks_cursor").and_then(Value::as_bool) == Some(true))
5418 .count();
5419 if blocking > 0 {
5420 println!(
5421 "pulled {} event(s); wrote {}; rejected {} ({} BLOCKING cursor — see `wire pull --json`)",
5422 events_len,
5423 result.written.len(),
5424 result.rejected.len(),
5425 blocking,
5426 );
5427 } else {
5428 println!(
5429 "pulled {} event(s); wrote {}; rejected {}",
5430 events_len,
5431 result.written.len(),
5432 result.rejected.len(),
5433 );
5434 }
5435 }
5436 Ok(())
5437}
5438
5439fn endpoint_cursor_key(scope: crate::endpoints::EndpointScope) -> String {
5444 match scope {
5445 crate::endpoints::EndpointScope::Federation => "last_pulled_event_id".to_string(),
5446 crate::endpoints::EndpointScope::Local => "last_pulled_event_id_local".to_string(),
5447 crate::endpoints::EndpointScope::Lan => "last_pulled_event_id_lan".to_string(),
5448 crate::endpoints::EndpointScope::Uds => "last_pulled_event_id_uds".to_string(),
5449 }
5450}
5451
5452fn cmd_rotate_slot(no_announce: bool, as_json: bool) -> Result<()> {
5455 if !config::is_initialized()? {
5456 bail!("not initialized — run `wire init <handle>` first");
5457 }
5458 let mut state = config::read_relay_state()?;
5459 let self_state = state.get("self").cloned().unwrap_or(Value::Null);
5460 if self_state.is_null() {
5461 bail!("self slot not bound — run `wire bind-relay <url>` first (nothing to rotate)");
5462 }
5463 let primary = crate::endpoints::self_primary_endpoint(&state)
5467 .ok_or_else(|| anyhow!("self has no resolvable inbound endpoint to rotate"))?;
5468 let url = primary.relay_url.clone();
5469 let old_slot_id = primary.slot_id.clone();
5470 let old_slot_token = primary.slot_token.clone();
5471
5472 let card = config::read_agent_card()?;
5474 let did = card
5475 .get("did")
5476 .and_then(Value::as_str)
5477 .unwrap_or("")
5478 .to_string();
5479 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
5480 let pk_b64 = card
5481 .get("verify_keys")
5482 .and_then(Value::as_object)
5483 .and_then(|m| m.values().next())
5484 .and_then(|v| v.get("key"))
5485 .and_then(Value::as_str)
5486 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?
5487 .to_string();
5488 let pk_bytes = crate::signing::b64decode(&pk_b64)?;
5489 let sk_seed = config::read_private_key()?;
5490
5491 let normalized = url.trim_end_matches('/').to_string();
5493 let client = crate::relay_client::RelayClient::new(&normalized);
5494 client
5495 .check_healthz()
5496 .context("aborting rotation; old slot still valid")?;
5497 let alloc = client.allocate_slot(Some(&handle))?;
5498 let new_slot_id = alloc.slot_id.clone();
5499 let new_slot_token = alloc.slot_token.clone();
5500
5501 let mut announced: Vec<String> = Vec::new();
5508 if !no_announce {
5509 let now = time::OffsetDateTime::now_utc()
5510 .format(&time::format_description::well_known::Rfc3339)
5511 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
5512 let body = json!({
5513 "reason": "operator-initiated slot rotation",
5514 "new_relay_url": url,
5515 "new_slot_id": new_slot_id,
5516 });
5520 let peers = state["peers"].as_object().cloned().unwrap_or_default();
5521 for (peer_handle, _peer_info) in peers.iter() {
5522 let event = json!({
5523 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
5524 "timestamp": now.clone(),
5525 "from": did,
5526 "to": format!("did:wire:{peer_handle}"),
5527 "type": "wire_close",
5528 "kind": 1201,
5529 "body": body.clone(),
5530 });
5531 let signed = match sign_message_v31(&event, &sk_seed, &pk_bytes, &handle) {
5532 Ok(s) => s,
5533 Err(e) => {
5534 eprintln!("warn: could not sign wire_close for {peer_handle}: {e}");
5535 continue;
5536 }
5537 };
5538 let peer_info = match state["peers"].get(peer_handle) {
5543 Some(p) => p.clone(),
5544 None => continue,
5545 };
5546 let peer_url = peer_info["relay_url"].as_str().unwrap_or(&url);
5547 let peer_slot_id = peer_info["slot_id"].as_str().unwrap_or("");
5548 let peer_slot_token = peer_info["slot_token"].as_str().unwrap_or("");
5549 if peer_slot_id.is_empty() || peer_slot_token.is_empty() {
5550 continue;
5551 }
5552 let peer_client = if peer_url == url {
5553 client.clone()
5554 } else {
5555 crate::relay_client::RelayClient::new(peer_url)
5556 };
5557 match peer_client.post_event(peer_slot_id, peer_slot_token, &signed) {
5558 Ok(_) => announced.push(peer_handle.clone()),
5559 Err(e) => eprintln!("warn: announce to {peer_handle} failed: {e}"),
5560 }
5561 }
5562 }
5563
5564 state["self"] = json!({
5566 "relay_url": url,
5567 "slot_id": new_slot_id,
5568 "slot_token": new_slot_token,
5569 });
5570 config::write_relay_state(&state)?;
5571
5572 if as_json {
5573 println!(
5574 "{}",
5575 serde_json::to_string(&json!({
5576 "rotated": true,
5577 "old_slot_id": old_slot_id,
5578 "new_slot_id": new_slot_id,
5579 "relay_url": url,
5580 "announced_to": announced,
5581 }))?
5582 );
5583 } else {
5584 println!("rotated slot on {url}");
5585 println!(
5586 " old slot_id: {old_slot_id} (orphaned — abusive bearer-holders lose their leverage)"
5587 );
5588 println!(" new slot_id: {new_slot_id}");
5589 if !announced.is_empty() {
5590 println!(
5591 " announced wire_close (kind=1201) to: {}",
5592 announced.join(", ")
5593 );
5594 }
5595 println!();
5596 println!("next steps:");
5597 println!(" - peers see the wire_close event in their next `wire pull`");
5598 println!(
5599 " - paired peers must re-issue: tell them to run `wire add-peer-slot {handle} {url} {new_slot_id} <new-token>`"
5600 );
5601 println!(" (or full re-pair via `wire pair-host`/`wire join`)");
5602 println!(" - until they do, you'll receive but they won't be able to reach you");
5603 let _ = old_slot_token;
5605 }
5606 Ok(())
5607}
5608
5609fn cmd_forget_peer(handle: &str, purge: bool, as_json: bool) -> Result<()> {
5612 let mut trust = config::read_trust()?;
5613 let mut removed_from_trust = false;
5614 if let Some(agents) = trust.get_mut("agents").and_then(Value::as_object_mut)
5615 && agents.remove(handle).is_some()
5616 {
5617 removed_from_trust = true;
5618 }
5619 config::write_trust(&trust)?;
5620
5621 let mut state = config::read_relay_state()?;
5622 let mut removed_from_relay = false;
5623 if let Some(peers) = state.get_mut("peers").and_then(Value::as_object_mut)
5624 && peers.remove(handle).is_some()
5625 {
5626 removed_from_relay = true;
5627 }
5628 config::write_relay_state(&state)?;
5629
5630 let mut purged: Vec<String> = Vec::new();
5631 if purge {
5632 for dir in [config::inbox_dir()?, config::outbox_dir()?] {
5633 let path = dir.join(format!("{handle}.jsonl"));
5634 if path.exists() {
5635 std::fs::remove_file(&path).with_context(|| format!("removing {path:?}"))?;
5636 purged.push(path.to_string_lossy().into());
5637 }
5638 }
5639 }
5640
5641 if !removed_from_trust && !removed_from_relay {
5642 if as_json {
5643 println!(
5644 "{}",
5645 serde_json::to_string(&json!({
5646 "removed": false,
5647 "reason": format!("peer {handle:?} not pinned"),
5648 }))?
5649 );
5650 } else {
5651 eprintln!("peer {handle:?} not found in trust or relay state — nothing to forget");
5652 }
5653 return Ok(());
5654 }
5655
5656 if as_json {
5657 println!(
5658 "{}",
5659 serde_json::to_string(&json!({
5660 "handle": handle,
5661 "removed_from_trust": removed_from_trust,
5662 "removed_from_relay_state": removed_from_relay,
5663 "purged_files": purged,
5664 }))?
5665 );
5666 } else {
5667 println!("forgot peer {handle:?}");
5668 if removed_from_trust {
5669 println!(" - removed from trust.json");
5670 }
5671 if removed_from_relay {
5672 println!(" - removed from relay.json");
5673 }
5674 if !purged.is_empty() {
5675 for p in &purged {
5676 println!(" - deleted {p}");
5677 }
5678 } else if !purge {
5679 println!(" (inbox/outbox files preserved; pass --purge to delete them)");
5680 }
5681 }
5682 Ok(())
5683}
5684
5685fn cmd_daemon(interval_secs: u64, once: bool, as_json: bool) -> Result<()> {
5688 if !config::is_initialized()? {
5689 bail!("not initialized — run `wire init <handle>` first");
5690 }
5691 if !once {
5696 crate::session::warn_on_identity_collision(std::process::id(), "daemon");
5697 }
5698 let interval = std::time::Duration::from_secs(interval_secs.max(1));
5699
5700 if !as_json {
5701 if once {
5702 eprintln!("wire daemon: single sync cycle, then exit");
5703 } else {
5704 eprintln!("wire daemon: syncing every {interval_secs}s. SIGINT to stop.");
5705 }
5706 }
5707
5708 if let Err(e) = crate::pending_pair::cleanup_on_startup() {
5712 eprintln!("daemon: pending-pair cleanup_on_startup error: {e:#}");
5713 }
5714
5715 let (wake_tx, wake_rx) = std::sync::mpsc::channel::<()>();
5721 if !once {
5722 crate::daemon_stream::spawn_stream_subscriber(wake_tx);
5723 }
5724
5725 loop {
5726 let pushed = run_sync_push().unwrap_or_else(|e| {
5727 eprintln!("daemon: push error: {e:#}");
5728 json!({"pushed": [], "skipped": [{"error": e.to_string()}]})
5729 });
5730 let pulled = run_sync_pull().unwrap_or_else(|e| {
5731 eprintln!("daemon: pull error: {e:#}");
5732 json!({"written": [], "rejected": [], "total_seen": 0, "error": e.to_string()})
5733 });
5734 let pairs = crate::pending_pair::tick().unwrap_or_else(|e| {
5735 eprintln!("daemon: pending-pair tick error: {e:#}");
5736 json!({"transitions": []})
5737 });
5738
5739 if as_json {
5740 println!(
5741 "{}",
5742 serde_json::to_string(&json!({
5743 "ts": time::OffsetDateTime::now_utc()
5744 .format(&time::format_description::well_known::Rfc3339)
5745 .unwrap_or_default(),
5746 "push": pushed,
5747 "pull": pulled,
5748 "pairs": pairs,
5749 }))?
5750 );
5751 } else {
5752 let pushed_n = pushed["pushed"].as_array().map(|a| a.len()).unwrap_or(0);
5753 let written_n = pulled["written"].as_array().map(|a| a.len()).unwrap_or(0);
5754 let rejected_n = pulled["rejected"].as_array().map(|a| a.len()).unwrap_or(0);
5755 let pair_transitions = pairs["transitions"]
5756 .as_array()
5757 .map(|a| a.len())
5758 .unwrap_or(0);
5759 if pushed_n > 0 || written_n > 0 || rejected_n > 0 || pair_transitions > 0 {
5760 eprintln!(
5761 "daemon: pushed={pushed_n} pulled={written_n} rejected={rejected_n} pair-transitions={pair_transitions}"
5762 );
5763 }
5764 if let Some(arr) = pairs["transitions"].as_array() {
5766 for t in arr {
5767 eprintln!(
5768 " pair {} : {} → {}",
5769 t.get("code").and_then(Value::as_str).unwrap_or("?"),
5770 t.get("from").and_then(Value::as_str).unwrap_or("?"),
5771 t.get("to").and_then(Value::as_str).unwrap_or("?")
5772 );
5773 if let Some(sas) = t.get("sas").and_then(Value::as_str)
5774 && t.get("to").and_then(Value::as_str) == Some("sas_ready")
5775 {
5776 eprintln!(" SAS digits: {}-{}", &sas[..3], &sas[3..]);
5777 eprintln!(
5778 " Run: wire pair-confirm {} {}",
5779 t.get("code").and_then(Value::as_str).unwrap_or("?"),
5780 sas
5781 );
5782 }
5783 }
5784 }
5785 }
5786
5787 if once {
5788 return Ok(());
5789 }
5790 match wake_rx.recv_timeout(interval) {
5803 Ok(()) | Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
5804 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
5805 std::thread::sleep(interval);
5806 }
5807 }
5808 while wake_rx.try_recv().is_ok() {}
5809 }
5810}
5811
5812fn run_sync_push() -> Result<Value> {
5815 let state = config::read_relay_state()?;
5816 let peers = state["peers"].as_object().cloned().unwrap_or_default();
5817 if peers.is_empty() {
5818 return Ok(json!({"pushed": [], "skipped": []}));
5819 }
5820 let outbox_dir = config::outbox_dir()?;
5821 if !outbox_dir.exists() {
5822 return Ok(json!({"pushed": [], "skipped": []}));
5823 }
5824 let mut pushed = Vec::new();
5825 let mut skipped = Vec::new();
5826 for (peer_handle, slot_info) in peers.iter() {
5827 let outbox = outbox_dir.join(format!("{peer_handle}.jsonl"));
5828 if !outbox.exists() {
5829 continue;
5830 }
5831 let url = slot_info["relay_url"].as_str().unwrap_or("");
5832 let slot_id = slot_info["slot_id"].as_str().unwrap_or("");
5833 let slot_token = slot_info["slot_token"].as_str().unwrap_or("");
5834 if url.is_empty() || slot_id.is_empty() || slot_token.is_empty() {
5835 continue;
5836 }
5837 let client = crate::relay_client::RelayClient::new(url);
5838 let body = std::fs::read_to_string(&outbox)?;
5839 for line in body.lines() {
5840 let event: Value = match serde_json::from_str(line) {
5841 Ok(v) => v,
5842 Err(_) => continue,
5843 };
5844 let event_id = event
5845 .get("event_id")
5846 .and_then(Value::as_str)
5847 .unwrap_or("")
5848 .to_string();
5849 match client.post_event(slot_id, slot_token, &event) {
5850 Ok(resp) => {
5851 if resp.status == "duplicate" {
5852 skipped.push(json!({"peer": peer_handle, "event_id": event_id, "reason": "duplicate"}));
5853 } else {
5854 pushed.push(json!({"peer": peer_handle, "event_id": event_id}));
5855 }
5856 }
5857 Err(e) => {
5858 let reason = crate::relay_client::format_transport_error(&e);
5862 skipped
5863 .push(json!({"peer": peer_handle, "event_id": event_id, "reason": reason}));
5864 }
5865 }
5866 }
5867 }
5868 Ok(json!({"pushed": pushed, "skipped": skipped}))
5869}
5870
5871fn run_sync_pull() -> Result<Value> {
5879 let state = config::read_relay_state()?;
5880 if state.get("self").map(Value::is_null).unwrap_or(true) {
5881 return Ok(json!({"written": [], "rejected": [], "total_seen": 0}));
5882 }
5883 let endpoints = crate::endpoints::self_endpoints(&state);
5890 if endpoints.is_empty() {
5891 return Ok(json!({"written": [], "rejected": [], "total_seen": 0}));
5892 }
5893 let inbox_dir = config::inbox_dir()?;
5894 config::ensure_dirs()?;
5895
5896 let self_obj = state.get("self").cloned().unwrap_or(Value::Null);
5901 let legacy_cursor = self_obj
5902 .get("last_pulled_event_id")
5903 .and_then(Value::as_str)
5904 .map(str::to_string);
5905 let primary_slot = crate::endpoints::self_primary_endpoint(&state).map(|e| e.slot_id);
5906 let mut cursors: serde_json::Map<String, Value> = self_obj
5907 .get("cursors")
5908 .and_then(Value::as_object)
5909 .cloned()
5910 .unwrap_or_default();
5911
5912 let mut all_written: Vec<Value> = Vec::new();
5913 let mut all_rejected: Vec<Value> = Vec::new();
5914 let mut total_seen = 0usize;
5915 let mut blocked_any = false;
5916
5917 for ep in &endpoints {
5918 if ep.relay_url.is_empty() {
5919 continue;
5920 }
5921 let cursor = cursors
5922 .get(&ep.slot_id)
5923 .and_then(Value::as_str)
5924 .map(str::to_string)
5925 .or_else(|| {
5926 if Some(&ep.slot_id) == primary_slot.as_ref() {
5927 legacy_cursor.clone()
5928 } else {
5929 None
5930 }
5931 });
5932 let client = crate::relay_client::RelayClient::new(&ep.relay_url);
5933 let events =
5936 match client.list_events(&ep.slot_id, &ep.slot_token, cursor.as_deref(), Some(1000)) {
5937 Ok(e) => e,
5938 Err(e) => {
5939 eprintln!(
5940 "daemon: pull error on {} slot {} (continuing): {e:#}",
5941 ep.relay_url, ep.slot_id
5942 );
5943 continue;
5944 }
5945 };
5946 total_seen += events.len();
5947 let result = crate::pull::process_events(&events, cursor, &inbox_dir)?;
5950 if let Some(eid) = &result.advance_cursor_to {
5951 cursors.insert(ep.slot_id.clone(), Value::String(eid.clone()));
5952 }
5953 blocked_any |= result.blocked;
5954 all_written.extend(result.written);
5955 all_rejected.extend(result.rejected);
5956 }
5957
5958 let primary_cursor = primary_slot
5962 .as_ref()
5963 .and_then(|s| cursors.get(s))
5964 .and_then(Value::as_str)
5965 .map(str::to_string);
5966 config::update_relay_state(|state| {
5967 if let Some(self_obj) = state.get_mut("self").and_then(Value::as_object_mut) {
5968 self_obj.insert("cursors".into(), Value::Object(cursors.clone()));
5969 if let Some(pc) = &primary_cursor {
5970 self_obj.insert("last_pulled_event_id".into(), Value::String(pc.clone()));
5971 }
5972 }
5973 Ok(())
5974 })?;
5975
5976 Ok(json!({
5977 "written": all_written,
5978 "rejected": all_rejected,
5979 "total_seen": total_seen,
5980 "cursor_blocked": blocked_any,
5981 "endpoints_pulled": endpoints.len(),
5982 }))
5983}
5984
5985fn cmd_pin(card_file: &str, as_json: bool) -> Result<()> {
5988 let body =
5989 std::fs::read_to_string(card_file).with_context(|| format!("reading {card_file}"))?;
5990 let card: Value =
5991 serde_json::from_str(&body).with_context(|| format!("parsing {card_file}"))?;
5992 crate::agent_card::verify_agent_card(&card)
5993 .map_err(|e| anyhow!("peer card signature invalid: {e}"))?;
5994
5995 let mut trust = config::read_trust()?;
5996 crate::trust::add_agent_card_pin(&mut trust, &card, Some("VERIFIED"));
5997
5998 let did = card.get("did").and_then(Value::as_str).unwrap_or("");
5999 let handle = crate::agent_card::display_handle_from_did(did).to_string();
6000 config::write_trust(&trust)?;
6001
6002 if as_json {
6003 println!(
6004 "{}",
6005 serde_json::to_string(&json!({
6006 "handle": handle,
6007 "did": did,
6008 "tier": "VERIFIED",
6009 "pinned": true,
6010 }))?
6011 );
6012 } else {
6013 println!("pinned {handle} ({did}) at tier VERIFIED");
6014 }
6015 Ok(())
6016}
6017
6018fn cmd_pair_host(relay_url: &str, auto_yes: bool, timeout_secs: u64) -> Result<()> {
6021 pair_orchestrate(relay_url, None, "host", auto_yes, timeout_secs)
6022}
6023
6024fn cmd_pair_join(
6025 code_phrase: &str,
6026 relay_url: &str,
6027 auto_yes: bool,
6028 timeout_secs: u64,
6029) -> Result<()> {
6030 pair_orchestrate(
6031 relay_url,
6032 Some(code_phrase),
6033 "guest",
6034 auto_yes,
6035 timeout_secs,
6036 )
6037}
6038
6039fn pair_orchestrate(
6045 relay_url: &str,
6046 code_in: Option<&str>,
6047 role: &str,
6048 auto_yes: bool,
6049 timeout_secs: u64,
6050) -> Result<()> {
6051 use crate::pair_session::{pair_session_finalize, pair_session_open, pair_session_try_sas};
6052
6053 let mut s = pair_session_open(role, relay_url, code_in)?;
6054
6055 if role == "host" {
6056 eprintln!();
6057 eprintln!("share this code phrase with your peer:");
6058 eprintln!();
6059 eprintln!(" {}", s.code);
6060 eprintln!();
6061 eprintln!(
6062 "waiting for peer to run `wire pair-join {} --relay {relay_url}` ...",
6063 s.code
6064 );
6065 } else {
6066 eprintln!();
6067 eprintln!("joined pair-slot on {relay_url} — waiting for host's SPAKE2 message ...");
6068 }
6069
6070 const HEARTBEAT_SECS: u64 = 10;
6075 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
6076 let started = std::time::Instant::now();
6077 let mut last_heartbeat = started;
6078 let formatted = loop {
6079 if let Some(sas) = pair_session_try_sas(&mut s)? {
6080 break sas;
6081 }
6082 let now = std::time::Instant::now();
6083 if now >= deadline {
6084 return Err(anyhow!(
6085 "timeout after {timeout_secs}s waiting for peer's SPAKE2 message"
6086 ));
6087 }
6088 if now.duration_since(last_heartbeat).as_secs() >= HEARTBEAT_SECS {
6089 let elapsed = now.duration_since(started).as_secs();
6090 eprintln!(" ... still waiting ({elapsed}s / {timeout_secs}s)");
6091 last_heartbeat = now;
6092 }
6093 std::thread::sleep(std::time::Duration::from_millis(250));
6094 };
6095
6096 eprintln!();
6097 eprintln!("SAS digits (must match peer's terminal):");
6098 eprintln!();
6099 eprintln!(" {formatted}");
6100 eprintln!();
6101
6102 if !auto_yes {
6105 eprint!("does this match your peer's terminal? [y/N]: ");
6106 use std::io::Write;
6107 std::io::stderr().flush().ok();
6108 let mut input = String::new();
6109 std::io::stdin().read_line(&mut input)?;
6110 let trimmed = input.trim().to_lowercase();
6111 if trimmed != "y" && trimmed != "yes" {
6112 bail!("SAS confirmation declined — aborting pairing");
6113 }
6114 }
6115 s.sas_confirmed = true;
6116
6117 let result = pair_session_finalize(&mut s, timeout_secs)?;
6119
6120 let peer_did = result["paired_with"].as_str().unwrap_or("");
6121 let peer_role = if role == "host" { "guest" } else { "host" };
6122 eprintln!("paired with {peer_did} (peer role: {peer_role})");
6123 eprintln!("peer card pinned at tier VERIFIED");
6124 eprintln!(
6125 "peer relay slot saved to {}",
6126 config::relay_state_path()?.display()
6127 );
6128
6129 println!("{}", serde_json::to_string(&result)?);
6130 Ok(())
6131}
6132
6133fn cmd_pair(
6139 handle: &str,
6140 code: Option<&str>,
6141 relay: &str,
6142 auto_yes: bool,
6143 timeout_secs: u64,
6144 no_setup: bool,
6145) -> Result<()> {
6146 let init_result = crate::pair_session::init_self_idempotent(handle, None, None)?;
6149 let did = init_result
6150 .get("did")
6151 .and_then(|v| v.as_str())
6152 .unwrap_or("(unknown)")
6153 .to_string();
6154 let already = init_result
6155 .get("already_initialized")
6156 .and_then(|v| v.as_bool())
6157 .unwrap_or(false);
6158 if already {
6159 println!("(identity {did} already initialized — reusing)");
6160 } else {
6161 println!("initialized {did}");
6162 }
6163 println!();
6164
6165 match code {
6167 None => {
6168 println!("hosting pair on {relay} (no code = host) ...");
6169 cmd_pair_host(relay, auto_yes, timeout_secs)?;
6170 }
6171 Some(c) => {
6172 println!("joining pair with code {c} on {relay} ...");
6173 cmd_pair_join(c, relay, auto_yes, timeout_secs)?;
6174 }
6175 }
6176
6177 if !no_setup {
6179 println!();
6180 println!("registering wire as MCP server in detected client configs ...");
6181 if let Err(e) = cmd_setup(true) {
6182 eprintln!("warn: setup --apply failed: {e}");
6184 eprintln!(" pair succeeded; you can re-run `wire setup --apply` manually.");
6185 }
6186 }
6187
6188 println!();
6189 println!("pair complete. Next steps:");
6190 println!(" wire daemon start # background sync of inbox/outbox vs relay");
6191 println!(" wire send <peer> claim <msg> # send your peer something");
6192 println!(" wire tail # watch incoming events");
6193 Ok(())
6194}
6195
6196fn cmd_pair_detach(handle: &str, code: Option<&str>, relay: &str) -> Result<()> {
6202 let init_result = crate::pair_session::init_self_idempotent(handle, None, None)?;
6203 let did = init_result
6204 .get("did")
6205 .and_then(|v| v.as_str())
6206 .unwrap_or("(unknown)")
6207 .to_string();
6208 let already = init_result
6209 .get("already_initialized")
6210 .and_then(|v| v.as_bool())
6211 .unwrap_or(false);
6212 if already {
6213 println!("(identity {did} already initialized — reusing)");
6214 } else {
6215 println!("initialized {did}");
6216 }
6217 println!();
6218 match code {
6219 None => cmd_pair_host_detach(relay, false),
6220 Some(c) => cmd_pair_join_detach(c, relay, false),
6221 }
6222}
6223
6224fn cmd_pair_host_detach(relay_url: &str, as_json: bool) -> Result<()> {
6225 if !config::is_initialized()? {
6226 bail!("not initialized — run `wire init <handle>` first");
6227 }
6228 let daemon_spawned = match crate::ensure_up::ensure_daemon_running() {
6229 Ok(b) => b,
6230 Err(e) => {
6231 if !as_json {
6232 eprintln!(
6233 "warn: could not auto-start daemon: {e}; pair will queue but not advance"
6234 );
6235 }
6236 false
6237 }
6238 };
6239 let code = crate::sas::generate_code_phrase();
6240 let code_hash = crate::pair_session::derive_code_hash(&code);
6241 let now = time::OffsetDateTime::now_utc()
6242 .format(&time::format_description::well_known::Rfc3339)
6243 .unwrap_or_default();
6244 let p = crate::pending_pair::PendingPair {
6245 code: code.clone(),
6246 code_hash,
6247 role: "host".to_string(),
6248 relay_url: relay_url.to_string(),
6249 status: "request_host".to_string(),
6250 sas: None,
6251 peer_did: None,
6252 created_at: now,
6253 last_error: None,
6254 pair_id: None,
6255 our_slot_id: None,
6256 our_slot_token: None,
6257 spake2_seed_b64: None,
6258 };
6259 crate::pending_pair::write_pending(&p)?;
6260 if as_json {
6261 println!(
6262 "{}",
6263 serde_json::to_string(&json!({
6264 "state": "queued",
6265 "code_phrase": code,
6266 "relay_url": relay_url,
6267 "role": "host",
6268 "daemon_spawned": daemon_spawned,
6269 }))?
6270 );
6271 } else {
6272 if daemon_spawned {
6273 println!("(started wire daemon in background)");
6274 }
6275 println!("detached pair-host queued. Share this code with your peer:\n");
6276 println!(" {code}\n");
6277 println!("Next steps:");
6278 println!(" wire pair-list # check status");
6279 println!(" wire pair-confirm {code} <digits> # when SAS shows up");
6280 println!(" wire pair-cancel {code} # to abort");
6281 }
6282 Ok(())
6283}
6284
6285fn cmd_pair_join_detach(code_phrase: &str, relay_url: &str, as_json: bool) -> Result<()> {
6286 if !config::is_initialized()? {
6287 bail!("not initialized — run `wire init <handle>` first");
6288 }
6289 let daemon_spawned = match crate::ensure_up::ensure_daemon_running() {
6290 Ok(b) => b,
6291 Err(e) => {
6292 if !as_json {
6293 eprintln!(
6294 "warn: could not auto-start daemon: {e}; pair will queue but not advance"
6295 );
6296 }
6297 false
6298 }
6299 };
6300 let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
6301 let code_hash = crate::pair_session::derive_code_hash(&code);
6302 let now = time::OffsetDateTime::now_utc()
6303 .format(&time::format_description::well_known::Rfc3339)
6304 .unwrap_or_default();
6305 let p = crate::pending_pair::PendingPair {
6306 code: code.clone(),
6307 code_hash,
6308 role: "guest".to_string(),
6309 relay_url: relay_url.to_string(),
6310 status: "request_guest".to_string(),
6311 sas: None,
6312 peer_did: None,
6313 created_at: now,
6314 last_error: None,
6315 pair_id: None,
6316 our_slot_id: None,
6317 our_slot_token: None,
6318 spake2_seed_b64: None,
6319 };
6320 crate::pending_pair::write_pending(&p)?;
6321 if as_json {
6322 println!(
6323 "{}",
6324 serde_json::to_string(&json!({
6325 "state": "queued",
6326 "code_phrase": code,
6327 "relay_url": relay_url,
6328 "role": "guest",
6329 "daemon_spawned": daemon_spawned,
6330 }))?
6331 );
6332 } else {
6333 if daemon_spawned {
6334 println!("(started wire daemon in background)");
6335 }
6336 println!("detached pair-join queued for code {code}.");
6337 println!(
6338 "Run `wire pair-list` to watch for SAS, then `wire pair-confirm {code} <digits>`."
6339 );
6340 }
6341 Ok(())
6342}
6343
6344fn cmd_pair_confirm(code_phrase: &str, typed_digits: &str, as_json: bool) -> Result<()> {
6345 let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
6346 let typed: String = typed_digits
6347 .chars()
6348 .filter(|c| c.is_ascii_digit())
6349 .collect();
6350 if typed.len() != 6 {
6351 bail!(
6352 "expected 6 digits (got {} after stripping non-digits)",
6353 typed.len()
6354 );
6355 }
6356 let mut p = crate::pending_pair::read_pending(&code)?
6357 .ok_or_else(|| anyhow!("no pending pair found for code {code}"))?;
6358 if p.status != "sas_ready" {
6359 bail!(
6360 "pair {code} not in sas_ready state (current: {}). Run `wire pair-list` to see what's going on.",
6361 p.status
6362 );
6363 }
6364 let stored = p
6365 .sas
6366 .as_ref()
6367 .ok_or_else(|| anyhow!("pending file has status=sas_ready but no sas field"))?
6368 .clone();
6369 if stored == typed {
6370 p.status = "confirmed".to_string();
6371 crate::pending_pair::write_pending(&p)?;
6372 if as_json {
6373 println!(
6374 "{}",
6375 serde_json::to_string(&json!({
6376 "state": "confirmed",
6377 "code_phrase": code,
6378 }))?
6379 );
6380 } else {
6381 println!("digits match. Daemon will finalize the handshake on its next tick.");
6382 println!("Run `wire peers` after a few seconds to confirm.");
6383 }
6384 } else {
6385 p.status = "aborted".to_string();
6386 p.last_error = Some(format!(
6387 "SAS digit mismatch (typed {typed}, expected {stored})"
6388 ));
6389 let client = crate::relay_client::RelayClient::new(&p.relay_url);
6390 let _ = client.pair_abandon(&p.code_hash);
6391 crate::pending_pair::write_pending(&p)?;
6392 crate::os_notify::toast(
6393 &format!("wire — pair aborted ({})", p.code),
6394 p.last_error.as_deref().unwrap_or("digits mismatch"),
6395 );
6396 if as_json {
6397 println!(
6398 "{}",
6399 serde_json::to_string(&json!({
6400 "state": "aborted",
6401 "code_phrase": code,
6402 "error": "digits mismatch",
6403 }))?
6404 );
6405 }
6406 bail!("digits mismatch — pair aborted. Re-issue with a fresh `wire pair-host --detach`.");
6407 }
6408 Ok(())
6409}
6410
6411fn cmd_pair_list(as_json: bool, watch: bool, watch_interval_secs: u64) -> Result<()> {
6412 if watch {
6413 return cmd_pair_list_watch(watch_interval_secs);
6414 }
6415 let spake2_items = crate::pending_pair::list_pending()?;
6416 let inbound_items = crate::pending_inbound_pair::list_pending_inbound()?;
6417 if as_json {
6418 println!("{}", serde_json::to_string(&spake2_items)?);
6423 return Ok(());
6424 }
6425 if spake2_items.is_empty() && inbound_items.is_empty() {
6426 println!("no pending pair sessions.");
6427 return Ok(());
6428 }
6429 if !inbound_items.is_empty() {
6432 println!("PENDING INBOUND (v0.5.14 zero-paste pair_drop awaiting your accept)");
6433 println!(
6434 "{:<20} {:<35} {:<25} NEXT STEP",
6435 "PEER", "RELAY", "RECEIVED"
6436 );
6437 for p in &inbound_items {
6438 println!(
6439 "{:<20} {:<35} {:<25} `wire pair-accept {peer}` to accept; `wire pair-reject {peer}` to refuse",
6440 p.peer_handle,
6441 p.peer_relay_url,
6442 p.received_at,
6443 peer = p.peer_handle,
6444 );
6445 }
6446 println!();
6447 }
6448 if !spake2_items.is_empty() {
6449 println!("SPAKE2 SESSIONS");
6450 println!(
6451 "{:<15} {:<8} {:<18} {:<10} NOTE",
6452 "CODE", "ROLE", "STATUS", "SAS"
6453 );
6454 for p in spake2_items {
6455 let sas = p
6456 .sas
6457 .as_ref()
6458 .map(|d| format!("{}-{}", &d[..3], &d[3..]))
6459 .unwrap_or_else(|| "—".to_string());
6460 let note = p
6461 .last_error
6462 .as_deref()
6463 .or(p.peer_did.as_deref())
6464 .unwrap_or("");
6465 println!(
6466 "{:<15} {:<8} {:<18} {:<10} {}",
6467 p.code, p.role, p.status, sas, note
6468 );
6469 }
6470 }
6471 Ok(())
6472}
6473
6474fn cmd_pair_list_watch(interval_secs: u64) -> Result<()> {
6486 use std::collections::HashMap;
6487 use std::io::Write;
6488 let interval = std::time::Duration::from_secs(interval_secs.max(1));
6489 let mut prev: HashMap<String, String> = HashMap::new();
6492 {
6493 let items = crate::pending_pair::list_pending()?;
6494 for p in &items {
6495 println!("{}", serde_json::to_string(&p)?);
6496 prev.insert(p.code.clone(), p.status.clone());
6497 }
6498 let _ = std::io::stdout().flush();
6500 }
6501 loop {
6502 std::thread::sleep(interval);
6503 let items = match crate::pending_pair::list_pending() {
6504 Ok(v) => v,
6505 Err(_) => continue,
6506 };
6507 let mut cur: HashMap<String, String> = HashMap::new();
6508 for p in &items {
6509 cur.insert(p.code.clone(), p.status.clone());
6510 match prev.get(&p.code) {
6511 None => {
6512 println!("{}", serde_json::to_string(&p)?);
6514 }
6515 Some(prev_status) if prev_status != &p.status => {
6516 println!("{}", serde_json::to_string(&p)?);
6518 }
6519 _ => {}
6520 }
6521 }
6522 for code in prev.keys() {
6523 if !cur.contains_key(code) {
6524 println!(
6527 "{}",
6528 serde_json::to_string(&json!({
6529 "code": code,
6530 "status": "removed",
6531 "_synthetic": true,
6532 }))?
6533 );
6534 }
6535 }
6536 let _ = std::io::stdout().flush();
6537 prev = cur;
6538 }
6539}
6540
6541fn cmd_pair_watch(
6545 code_phrase: &str,
6546 target_status: &str,
6547 timeout_secs: u64,
6548 as_json: bool,
6549) -> Result<()> {
6550 let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
6551 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
6552 let mut last_seen_status: Option<String> = None;
6553 loop {
6554 let p_opt = crate::pending_pair::read_pending(&code)?;
6555 let now = std::time::Instant::now();
6556 match p_opt {
6557 None => {
6558 if last_seen_status.is_some() {
6562 if as_json {
6563 println!(
6564 "{}",
6565 serde_json::to_string(&json!({"state": "finalized", "code": code}))?
6566 );
6567 } else {
6568 println!("pair {code} finalized (file removed)");
6569 }
6570 return Ok(());
6571 } else {
6572 if as_json {
6573 println!(
6574 "{}",
6575 serde_json::to_string(&json!({"error": "no such pair", "code": code}))?
6576 );
6577 }
6578 std::process::exit(1);
6579 }
6580 }
6581 Some(p) => {
6582 let cur = p.status.clone();
6583 if Some(cur.clone()) != last_seen_status {
6584 if as_json {
6585 println!("{}", serde_json::to_string(&p)?);
6587 }
6588 last_seen_status = Some(cur.clone());
6589 }
6590 if cur == target_status {
6591 if !as_json {
6592 let sas_str = p
6593 .sas
6594 .as_ref()
6595 .map(|s| format!("{}-{}", &s[..3], &s[3..]))
6596 .unwrap_or_else(|| "—".to_string());
6597 println!("pair {code} reached {target_status} (SAS: {sas_str})");
6598 }
6599 return Ok(());
6600 }
6601 if cur == "aborted" || cur == "aborted_restart" {
6602 if !as_json {
6603 let err = p.last_error.as_deref().unwrap_or("(no detail)");
6604 eprintln!("pair {code} {cur}: {err}");
6605 }
6606 std::process::exit(1);
6607 }
6608 }
6609 }
6610 if now >= deadline {
6611 if !as_json {
6612 eprintln!(
6613 "timeout after {timeout_secs}s waiting for pair {code} to reach {target_status}"
6614 );
6615 }
6616 std::process::exit(2);
6617 }
6618 std::thread::sleep(std::time::Duration::from_millis(250));
6619 }
6620}
6621
6622fn cmd_pair_cancel(code_phrase: &str, as_json: bool) -> Result<()> {
6623 let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
6624 let p = crate::pending_pair::read_pending(&code)?
6625 .ok_or_else(|| anyhow!("no pending pair for code {code}"))?;
6626 let client = crate::relay_client::RelayClient::new(&p.relay_url);
6627 let _ = client.pair_abandon(&p.code_hash);
6628 crate::pending_pair::delete_pending(&code)?;
6629 if as_json {
6630 println!(
6631 "{}",
6632 serde_json::to_string(&json!({
6633 "state": "cancelled",
6634 "code_phrase": code,
6635 }))?
6636 );
6637 } else {
6638 println!("cancelled pending pair {code} (relay slot released, file removed).");
6639 }
6640 Ok(())
6641}
6642
6643fn cmd_pair_abandon(code_phrase: &str, relay_url: &str) -> Result<()> {
6646 let code = crate::sas::parse_code_phrase(code_phrase)?;
6649 let code_hash = crate::pair_session::derive_code_hash(code);
6650 let client = crate::relay_client::RelayClient::new(relay_url);
6651 client.pair_abandon(&code_hash)?;
6652 println!("abandoned pair-slot for code {code_phrase} on {relay_url}");
6653 println!("host can now issue a fresh code; guest can re-join.");
6654 Ok(())
6655}
6656
6657fn cmd_invite(relay: &str, ttl: u64, uses: u32, share: bool, as_json: bool) -> Result<()> {
6660 let url = crate::pair_invite::mint_invite(Some(ttl), uses, Some(relay))?;
6661
6662 let share_payload: Option<Value> = if share {
6665 let client = reqwest::blocking::Client::new();
6666 let single_use = if uses == 1 { Some(1u32) } else { None };
6667 let body = json!({
6668 "invite_url": url,
6669 "ttl_seconds": ttl,
6670 "uses": single_use,
6671 });
6672 let endpoint = format!("{}/v1/invite/register", relay.trim_end_matches('/'));
6673 let resp = client.post(&endpoint).json(&body).send()?;
6674 if !resp.status().is_success() {
6675 let code = resp.status();
6676 let txt = resp.text().unwrap_or_default();
6677 bail!("relay {code} on /v1/invite/register: {txt}");
6678 }
6679 let parsed: Value = resp.json()?;
6680 let token = parsed
6681 .get("token")
6682 .and_then(Value::as_str)
6683 .ok_or_else(|| anyhow::anyhow!("relay reply missing token"))?
6684 .to_string();
6685 let share_url = format!("{}/i/{}", relay.trim_end_matches('/'), token);
6686 let curl_line = format!("curl -fsSL {share_url} | sh");
6687 Some(json!({
6688 "token": token,
6689 "share_url": share_url,
6690 "curl": curl_line,
6691 "expires_unix": parsed.get("expires_unix"),
6692 }))
6693 } else {
6694 None
6695 };
6696
6697 if as_json {
6698 let mut out = json!({
6699 "invite_url": url,
6700 "ttl_secs": ttl,
6701 "uses": uses,
6702 "relay": relay,
6703 });
6704 if let Some(s) = &share_payload {
6705 out["share"] = s.clone();
6706 }
6707 println!("{}", serde_json::to_string(&out)?);
6708 } else if let Some(s) = share_payload {
6709 let curl = s.get("curl").and_then(Value::as_str).unwrap_or("");
6710 eprintln!("# One-curl onboarding. Share this single line — installs wire if missing,");
6711 eprintln!("# accepts the invite, pairs both sides. TTL: {ttl}s. Uses: {uses}.");
6712 println!("{curl}");
6713 } else {
6714 eprintln!("# Share this URL with one peer. Pasting it = pair complete on their side.");
6715 eprintln!("# TTL: {ttl}s. Uses: {uses}.");
6716 println!("{url}");
6717 }
6718 Ok(())
6719}
6720
6721fn cmd_accept(url: &str, as_json: bool) -> Result<()> {
6722 let resolved = if url.starts_with("http://") || url.starts_with("https://") {
6726 let sep = if url.contains('?') { '&' } else { '?' };
6727 let resolve_url = format!("{url}{sep}format=url");
6728 let client = reqwest::blocking::Client::new();
6729 let resp = client
6730 .get(&resolve_url)
6731 .send()
6732 .with_context(|| format!("GET {resolve_url}"))?;
6733 if !resp.status().is_success() {
6734 bail!("could not resolve short URL {url} (HTTP {})", resp.status());
6735 }
6736 let body = resp.text().unwrap_or_default().trim().to_string();
6737 if !body.starts_with("wire://pair?") {
6738 bail!(
6739 "short URL {url} did not resolve to a wire:// invite. \
6740 (got: {}{})",
6741 body.chars().take(80).collect::<String>(),
6742 if body.chars().count() > 80 { "…" } else { "" }
6743 );
6744 }
6745 body
6746 } else {
6747 url.to_string()
6748 };
6749
6750 let result = crate::pair_invite::accept_invite(&resolved)?;
6751 if as_json {
6752 println!("{}", serde_json::to_string(&result)?);
6753 } else {
6754 let did = result
6755 .get("paired_with")
6756 .and_then(Value::as_str)
6757 .unwrap_or("?");
6758 println!("paired with {did}");
6759 println!(
6760 "you can now: wire send {} <kind> <body>",
6761 crate::agent_card::display_handle_from_did(did)
6762 );
6763 }
6764 Ok(())
6765}
6766
6767fn cmd_whois(handle: Option<&str>, as_json: bool, relay_override: Option<&str>) -> Result<()> {
6770 if let Some(h) = handle {
6771 let parsed = crate::pair_profile::parse_handle(h)?;
6772 if config::is_initialized()? {
6775 let card = config::read_agent_card()?;
6776 let local_handle = card
6777 .get("profile")
6778 .and_then(|p| p.get("handle"))
6779 .and_then(Value::as_str)
6780 .map(str::to_string);
6781 if local_handle.as_deref() == Some(h) {
6782 return cmd_whois(None, as_json, None);
6783 }
6784 }
6785 let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)?;
6787 if as_json {
6788 println!("{}", serde_json::to_string(&resolved)?);
6789 } else {
6790 print_resolved_profile(&resolved);
6791 }
6792 return Ok(());
6793 }
6794 let card = config::read_agent_card()?;
6795 if as_json {
6796 let profile = card.get("profile").cloned().unwrap_or(Value::Null);
6797 let mut payload = serde_json::Map::new();
6798 payload.insert(
6799 "did".into(),
6800 card.get("did").cloned().unwrap_or(Value::Null),
6801 );
6802 payload.insert("profile".into(), profile);
6803 for (k, v) in op_claims_from_card(&card) {
6807 payload.insert(k, v);
6808 }
6809 println!("{}", serde_json::to_string(&payload)?);
6810 } else {
6811 print!("{}", crate::pair_profile::render_self_summary()?);
6812 }
6813 Ok(())
6814}
6815
6816fn print_resolved_profile(resolved: &Value) {
6817 let did = resolved.get("did").and_then(Value::as_str).unwrap_or("?");
6818 let nick = resolved.get("nick").and_then(Value::as_str).unwrap_or("?");
6819 let relay = resolved
6820 .get("relay_url")
6821 .and_then(Value::as_str)
6822 .unwrap_or("");
6823 let slot = resolved
6824 .get("slot_id")
6825 .and_then(Value::as_str)
6826 .unwrap_or("");
6827 let profile = resolved
6828 .get("card")
6829 .and_then(|c| c.get("profile"))
6830 .cloned()
6831 .unwrap_or(Value::Null);
6832 println!("{did}");
6833 println!(" nick: {nick}");
6834 if !relay.is_empty() {
6835 println!(" relay_url: {relay}");
6836 }
6837 if !slot.is_empty() {
6838 println!(" slot_id: {slot}");
6839 }
6840 let pick =
6841 |k: &str| -> Option<String> { profile.get(k).and_then(Value::as_str).map(str::to_string) };
6842 if let Some(s) = pick("display_name") {
6843 println!(" display_name: {s}");
6844 }
6845 if let Some(s) = pick("emoji") {
6846 println!(" emoji: {s}");
6847 }
6848 if let Some(s) = pick("motto") {
6849 println!(" motto: {s}");
6850 }
6851 if let Some(arr) = profile.get("vibe").and_then(Value::as_array) {
6852 let joined: Vec<String> = arr
6853 .iter()
6854 .filter_map(|v| v.as_str().map(str::to_string))
6855 .collect();
6856 println!(" vibe: {}", joined.join(", "));
6857 }
6858 if let Some(s) = pick("pronouns") {
6859 println!(" pronouns: {s}");
6860 }
6861}
6862
6863fn host_of_url(url: &str) -> String {
6871 let no_scheme = url
6872 .trim_start_matches("https://")
6873 .trim_start_matches("http://");
6874 no_scheme
6875 .split('/')
6876 .next()
6877 .unwrap_or("")
6878 .split(':')
6879 .next()
6880 .unwrap_or("")
6881 .to_string()
6882}
6883
6884fn is_known_relay_domain(peer_domain: &str, our_relay_url: &str) -> bool {
6888 const KNOWN_GOOD: &[&str] = &["wireup.net", "wire.laulpogan.com"];
6890 let peer_domain = peer_domain.trim().to_ascii_lowercase();
6891 if KNOWN_GOOD.iter().any(|k| *k == peer_domain) {
6892 return true;
6893 }
6894 let our_host = host_of_url(our_relay_url).to_ascii_lowercase();
6897 if !our_host.is_empty() && our_host == peer_domain {
6898 return true;
6899 }
6900 false
6901}
6902
6903fn resolve_local_session<'a>(
6921 sessions: &'a [crate::session::SessionInfo],
6922 input: &str,
6923) -> Result<&'a crate::session::SessionInfo, ResolveError> {
6924 if let Some(s) = sessions.iter().find(|s| s.name == input) {
6927 return Ok(s);
6928 }
6929 let nick_matches: Vec<&crate::session::SessionInfo> = sessions
6930 .iter()
6931 .filter(|s| {
6932 s.character
6933 .as_ref()
6934 .map(|c| c.nickname == input)
6935 .unwrap_or(false)
6936 })
6937 .collect();
6938 match nick_matches.len() {
6939 0 => Err(ResolveError::NotFound),
6940 1 => Ok(nick_matches[0]),
6941 _ => Err(ResolveError::Ambiguous(
6942 nick_matches.iter().map(|s| s.name.clone()).collect(),
6943 )),
6944 }
6945}
6946
6947#[derive(Debug)]
6948enum ResolveError {
6949 NotFound,
6950 Ambiguous(Vec<String>),
6951}
6952
6953fn resolve_peer_handle(input: &str) -> Result<Option<String>, ResolveError> {
6969 let trust = match config::read_trust() {
6970 Ok(t) => t,
6971 Err(_) => return Ok(None),
6972 };
6973 let agents = match trust.get("agents").and_then(|a| a.as_object()) {
6974 Some(a) => a,
6975 None => return Ok(None),
6976 };
6977 if agents.contains_key(input) {
6978 return Ok(Some(input.to_string()));
6979 }
6980 let mut nick_matches: Vec<String> = Vec::new();
6981 for (handle, agent) in agents.iter() {
6982 let character = match agent.get("card") {
6986 Some(card) => crate::character::Character::from_card(card),
6987 None => match agent.get("did").and_then(Value::as_str) {
6988 Some(did) => crate::character::Character::from_did(did),
6989 None => continue,
6990 },
6991 };
6992 if character.nickname == input {
6993 nick_matches.push(handle.clone());
6994 }
6995 }
6996 match nick_matches.len() {
6997 0 => Ok(None),
6998 1 => Ok(Some(nick_matches.into_iter().next().unwrap())),
6999 _ => Err(ResolveError::Ambiguous(nick_matches)),
7000 }
7001}
7002
7003fn cmd_add_local_sister(sister_name: &str, as_json: bool) -> Result<()> {
7004 let sessions = crate::session::list_sessions()?;
7006 let sister = match resolve_local_session(&sessions, sister_name) {
7007 Ok(s) => s,
7008 Err(ResolveError::NotFound) => bail!(
7009 "no sister session named `{sister_name}` (matched by session name or character nickname). \
7010 Run `wire session list` to see what's available."
7011 ),
7012 Err(ResolveError::Ambiguous(candidates)) => bail!(
7013 "nickname `{sister_name}` is ambiguous — matches {} sessions: {}. \
7014 Disambiguate by passing the session name (one of those listed) instead of the nickname.",
7015 candidates.len(),
7016 candidates.join(", ")
7017 ),
7018 };
7019 if sister.name != sister_name {
7022 eprintln!(
7023 "wire add: resolved nickname `{sister_name}` → session `{}`",
7024 sister.name
7025 );
7026 }
7027
7028 let our_card = config::read_agent_card()
7031 .map_err(|_| anyhow!("not initialized — run `wire init <handle>` first"))?;
7032 let our_did = our_card
7033 .get("did")
7034 .and_then(Value::as_str)
7035 .ok_or_else(|| anyhow!("agent-card missing did"))?
7036 .to_string();
7037 if let Some(sister_did) = sister.did.as_deref()
7038 && sister_did == our_did
7039 {
7040 bail!("refusing to add self (`{sister_name}` is this very session)");
7041 }
7042
7043 let sister_card_path = sister
7045 .home_dir
7046 .join("config")
7047 .join("wire")
7048 .join("agent-card.json");
7049 let sister_card: Value = serde_json::from_slice(
7050 &std::fs::read(&sister_card_path)
7051 .with_context(|| format!("reading sister card {sister_card_path:?}"))?,
7052 )
7053 .with_context(|| format!("parsing sister card {sister_card_path:?}"))?;
7054 let sister_relay_state: Value = std::fs::read(
7055 sister
7056 .home_dir
7057 .join("config")
7058 .join("wire")
7059 .join("relay.json"),
7060 )
7061 .ok()
7062 .and_then(|b| serde_json::from_slice(&b).ok())
7063 .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
7064
7065 let sister_did = sister_card
7066 .get("did")
7067 .and_then(Value::as_str)
7068 .ok_or_else(|| anyhow!("sister card missing did"))?
7069 .to_string();
7070 let sister_handle = crate::agent_card::display_handle_from_did(&sister_did).to_string();
7071
7072 let sister_endpoints = crate::endpoints::self_endpoints(&sister_relay_state);
7076 if sister_endpoints.is_empty() {
7077 bail!(
7078 "sister `{sister_name}` has no endpoints in its relay.json — recreate with `wire session new --local-only` or `--with-local`"
7079 );
7080 }
7081 let sister_local = sister_endpoints
7082 .iter()
7083 .find(|e| e.scope == crate::endpoints::EndpointScope::Local);
7084 let delivery_endpoint = match sister_local {
7085 Some(e) => e.clone(),
7086 None => sister_endpoints[0].clone(),
7087 };
7088
7089 let our_relay_state = config::read_relay_state()?;
7095 let our_endpoints = crate::endpoints::self_endpoints(&our_relay_state);
7096 if our_endpoints.is_empty() {
7097 bail!(
7098 "this session has no endpoints — run `wire session new --local-only` or `wire bind-relay` first"
7099 );
7100 }
7101 let our_advertised = our_endpoints
7102 .iter()
7103 .find(|e| e.scope == crate::endpoints::EndpointScope::Federation)
7104 .cloned()
7105 .unwrap_or_else(|| our_endpoints[0].clone());
7106
7107 let mut trust = config::read_trust()?;
7111 crate::trust::add_agent_card_pin(&mut trust, &sister_card, Some("VERIFIED"));
7112 config::write_trust(&trust)?;
7113 let mut relay_state = config::read_relay_state()?;
7114 crate::endpoints::pin_peer_endpoints(&mut relay_state, &sister_handle, &sister_endpoints)?;
7115 config::write_relay_state(&relay_state)?;
7116
7117 let sk_seed = config::read_private_key()?;
7120 let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
7121 let pk_b64 = our_card
7122 .get("verify_keys")
7123 .and_then(Value::as_object)
7124 .and_then(|m| m.values().next())
7125 .and_then(|v| v.get("key"))
7126 .and_then(Value::as_str)
7127 .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
7128 let pk_bytes = crate::signing::b64decode(pk_b64)?;
7129 let now = time::OffsetDateTime::now_utc()
7130 .format(&time::format_description::well_known::Rfc3339)
7131 .unwrap_or_default();
7132 let mut body = json!({
7133 "card": our_card,
7134 "relay_url": our_advertised.relay_url,
7135 "slot_id": our_advertised.slot_id,
7136 "slot_token": our_advertised.slot_token,
7137 });
7138 body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
7139 let event = json!({
7140 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
7141 "timestamp": now,
7142 "from": our_did,
7143 "to": sister_did,
7144 "type": "pair_drop",
7145 "kind": 1100u32,
7146 "body": body,
7147 });
7148 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
7149 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
7150
7151 let client = crate::relay_client::RelayClient::new(&delivery_endpoint.relay_url);
7155 client
7156 .post_event(
7157 &delivery_endpoint.slot_id,
7158 &delivery_endpoint.slot_token,
7159 &signed,
7160 )
7161 .with_context(|| format!("delivering pair_drop to `{sister_name}`'s local slot"))?;
7162
7163 if as_json {
7164 println!(
7165 "{}",
7166 serde_json::to_string(&json!({
7167 "handle": sister_name,
7168 "paired_with": sister_did,
7169 "peer_handle": sister_handle,
7170 "event_id": event_id,
7171 "delivered_via": match delivery_endpoint.scope {
7172 crate::endpoints::EndpointScope::Local => "local",
7173 crate::endpoints::EndpointScope::Lan => "lan",
7174 crate::endpoints::EndpointScope::Uds => "uds",
7175 crate::endpoints::EndpointScope::Federation => "federation",
7176 },
7177 "status": "drop_sent",
7178 }))?
7179 );
7180 } else {
7181 let scope = match delivery_endpoint.scope {
7182 crate::endpoints::EndpointScope::Local => "local",
7183 crate::endpoints::EndpointScope::Lan => "lan",
7184 crate::endpoints::EndpointScope::Uds => "uds",
7185 crate::endpoints::EndpointScope::Federation => "federation",
7186 };
7187 println!(
7188 "→ 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.",
7189 delivery_endpoint.relay_url
7190 );
7191 }
7192 Ok(())
7193}
7194
7195fn cmd_add(
7196 handle_arg: &str,
7197 relay_override: Option<&str>,
7198 local_sister: bool,
7199 as_json: bool,
7200) -> Result<()> {
7201 if local_sister {
7209 let resolved = crate::session::resolve_local_sister(handle_arg)
7210 .unwrap_or_else(|| handle_arg.to_string());
7211 return cmd_add_local_sister(&resolved, as_json);
7212 }
7213 if !handle_arg.contains('@')
7214 && let Some(resolved) = crate::session::resolve_local_sister(handle_arg)
7215 {
7216 eprintln!(
7217 "wire add: `{handle_arg}` resolved to local sister session `{resolved}` \
7218 — routing via --local-sister (disk-read card, no relay lookup)."
7219 );
7220 return cmd_add_local_sister(&resolved, as_json);
7221 }
7222 if !handle_arg.contains('@') {
7223 bail!(
7224 "`{handle_arg}` doesn't match any local sister session and has no \
7225 @<relay> suffix for federation.\n\
7226 — Local sisters: `wire session list-local` (operator types name OR \
7227 character nickname)\n\
7228 — Federation: `wire add <handle>@<relay-domain>` (e.g. \
7229 `wire add alice@wireup.net`)"
7230 );
7231 }
7232 let parsed = crate::pair_profile::parse_handle(handle_arg)?;
7233
7234 let (our_did, our_relay, our_slot_id, our_slot_token) =
7236 crate::pair_invite::ensure_self_with_relay(relay_override)?;
7237 if our_did == format!("did:wire:{}", parsed.nick) {
7238 bail!("refusing to add self (handle matches own DID)");
7240 }
7241
7242 if let Some(pending) = crate::pending_inbound_pair::read_pending_inbound(&parsed.nick)? {
7252 return cmd_add_accept_pending(
7253 handle_arg,
7254 &parsed.nick,
7255 &pending,
7256 &our_relay,
7257 &our_slot_id,
7258 &our_slot_token,
7259 as_json,
7260 );
7261 }
7262
7263 if !is_known_relay_domain(&parsed.domain, &our_relay) {
7280 eprintln!(
7281 "wire add: WARN unfamiliar relay domain `{}`.",
7282 parsed.domain
7283 );
7284 eprintln!(
7285 " This is NOT `wireup.net` (the default), NOT your own relay (`{}`), ",
7286 host_of_url(&our_relay)
7287 );
7288 eprintln!(
7289 " and not on the known-good list. If you meant `{}@wireup.net`, ",
7290 parsed.nick
7291 );
7292 eprintln!(
7293 " run `wire add {}@wireup.net` instead. Otherwise verify with your",
7294 parsed.nick
7295 );
7296 eprintln!(" peer out-of-band that they actually run a relay at this domain");
7297 eprintln!(" before relying on the pair. (See issue #9.4.)");
7298 }
7299
7300 let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)?;
7302 let peer_card = resolved
7303 .get("card")
7304 .cloned()
7305 .ok_or_else(|| anyhow!("resolved missing card"))?;
7306 let peer_did = resolved
7307 .get("did")
7308 .and_then(Value::as_str)
7309 .ok_or_else(|| anyhow!("resolved missing did"))?
7310 .to_string();
7311 let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
7312
7313 reject_self_pair_after_resolution(&our_did, &peer_did)?;
7318
7319 let peer_slot_id = resolved
7320 .get("slot_id")
7321 .and_then(Value::as_str)
7322 .ok_or_else(|| anyhow!("resolved missing slot_id"))?
7323 .to_string();
7324 let peer_relay = resolved
7325 .get("relay_url")
7326 .and_then(Value::as_str)
7327 .map(str::to_string)
7328 .or_else(|| relay_override.map(str::to_string))
7329 .unwrap_or_else(|| format!("https://{}", parsed.domain));
7330
7331 let mut trust = config::read_trust()?;
7333 crate::trust::add_agent_card_pin(&mut trust, &peer_card, Some("VERIFIED"));
7334 config::write_trust(&trust)?;
7335 let mut relay_state = config::read_relay_state()?;
7336 let mut endpoints: Vec<crate::endpoints::Endpoint> = relay_state
7349 .get("peers")
7350 .and_then(|p| p.get(&peer_handle))
7351 .and_then(|e| e.get("endpoints"))
7352 .and_then(|a| serde_json::from_value::<Vec<crate::endpoints::Endpoint>>(a.clone()).ok())
7353 .unwrap_or_default();
7354 let fed_token = endpoints
7355 .iter()
7356 .find(|e| {
7357 e.relay_url == peer_relay && e.scope == crate::endpoints::EndpointScope::Federation
7358 })
7359 .map(|e| e.slot_token.clone())
7360 .unwrap_or_default();
7361 let fed_ep = crate::endpoints::Endpoint {
7362 relay_url: peer_relay.clone(),
7363 slot_id: peer_slot_id.clone(),
7364 slot_token: fed_token, scope: crate::endpoints::EndpointScope::Federation,
7366 };
7367 if let Some(existing) = endpoints
7368 .iter_mut()
7369 .find(|e| e.relay_url == fed_ep.relay_url)
7370 {
7371 *existing = fed_ep;
7372 } else {
7373 endpoints.push(fed_ep);
7374 }
7375 crate::endpoints::pin_peer_endpoints(&mut relay_state, &peer_handle, &endpoints)?;
7376 config::write_relay_state(&relay_state)?;
7377
7378 let our_card = config::read_agent_card()?;
7381 let sk_seed = config::read_private_key()?;
7382 let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
7383 let pk_b64 = our_card
7384 .get("verify_keys")
7385 .and_then(Value::as_object)
7386 .and_then(|m| m.values().next())
7387 .and_then(|v| v.get("key"))
7388 .and_then(Value::as_str)
7389 .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
7390 let pk_bytes = crate::signing::b64decode(pk_b64)?;
7391 let now = time::OffsetDateTime::now_utc()
7392 .format(&time::format_description::well_known::Rfc3339)
7393 .unwrap_or_default();
7394 let our_relay_state = config::read_relay_state().unwrap_or_else(|_| json!({}));
7399 let our_endpoints = crate::endpoints::self_endpoints(&our_relay_state);
7400 let mut body = json!({
7401 "card": our_card,
7402 "relay_url": our_relay,
7403 "slot_id": our_slot_id,
7404 "slot_token": our_slot_token,
7405 });
7406 if !our_endpoints.is_empty() {
7407 body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
7408 }
7409 let event = json!({
7410 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
7411 "timestamp": now,
7412 "from": our_did,
7413 "to": peer_did,
7414 "type": "pair_drop",
7415 "kind": 1100u32,
7416 "body": body,
7417 });
7418 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
7419
7420 let client = crate::relay_client::RelayClient::new(&peer_relay);
7422 let resp = client.handle_intro(&parsed.nick, &signed)?;
7423 let event_id = signed
7424 .get("event_id")
7425 .and_then(Value::as_str)
7426 .unwrap_or("")
7427 .to_string();
7428
7429 if as_json {
7430 println!(
7431 "{}",
7432 serde_json::to_string(&json!({
7433 "handle": handle_arg,
7434 "paired_with": peer_did,
7435 "peer_handle": peer_handle,
7436 "event_id": event_id,
7437 "drop_response": resp,
7438 "status": "drop_sent",
7439 }))?
7440 );
7441 } else {
7442 println!(
7443 "→ 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."
7444 );
7445 }
7446 Ok(())
7447}
7448
7449fn cmd_add_accept_pending(
7456 handle_arg: &str,
7457 peer_nick: &str,
7458 pending: &crate::pending_inbound_pair::PendingInboundPair,
7459 _our_relay: &str,
7460 _our_slot_id: &str,
7461 _our_slot_token: &str,
7462 as_json: bool,
7463) -> Result<()> {
7464 let mut trust = config::read_trust()?;
7467 crate::trust::add_agent_card_pin(&mut trust, &pending.peer_card, Some("VERIFIED"));
7468 config::write_trust(&trust)?;
7469
7470 let mut relay_state = config::read_relay_state()?;
7476 let endpoints_to_pin = if pending.peer_endpoints.is_empty() {
7477 vec![crate::endpoints::Endpoint::federation(
7478 pending.peer_relay_url.clone(),
7479 pending.peer_slot_id.clone(),
7480 pending.peer_slot_token.clone(),
7481 )]
7482 } else {
7483 pending.peer_endpoints.clone()
7484 };
7485 crate::endpoints::pin_peer_endpoints(
7486 &mut relay_state,
7487 &pending.peer_handle,
7488 &endpoints_to_pin,
7489 )?;
7490 config::write_relay_state(&relay_state)?;
7491
7492 crate::pair_invite::send_pair_drop_ack(&pending.peer_handle, &endpoints_to_pin).with_context(
7497 || {
7498 format!(
7499 "pair_drop_ack send to {} (across {} endpoint(s)) failed",
7500 pending.peer_handle,
7501 endpoints_to_pin.len()
7502 )
7503 },
7504 )?;
7505
7506 crate::pending_inbound_pair::consume_pending_inbound(peer_nick)?;
7508
7509 if as_json {
7510 println!(
7511 "{}",
7512 serde_json::to_string(&json!({
7513 "handle": handle_arg,
7514 "paired_with": pending.peer_did,
7515 "peer_handle": pending.peer_handle,
7516 "status": "bilateral_accepted",
7517 "via": "pending_inbound",
7518 }))?
7519 );
7520 } else {
7521 println!(
7522 "→ 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} \"...\"`.",
7523 peer = pending.peer_handle,
7524 );
7525 }
7526 Ok(())
7527}
7528
7529fn cmd_pair_accept(peer_nick: &str, as_json: bool) -> Result<()> {
7536 let nick = crate::agent_card::bare_handle(peer_nick);
7537 let pending = crate::pending_inbound_pair::read_pending_inbound(nick)?.ok_or_else(|| {
7538 anyhow!(
7539 "no pending pair request from {nick}. Run `wire pair-list-inbound` to see who is waiting, \
7540 or use `wire add <peer>@<relay>` to send a fresh outbound pair request."
7541 )
7542 })?;
7543 let (_our_did, our_relay, our_slot_id, our_slot_token) =
7544 crate::pair_invite::ensure_self_with_relay(None)?;
7545 let handle_arg = format!("{}@{}", pending.peer_handle, pending.peer_relay_url);
7546 cmd_add_accept_pending(
7547 &handle_arg,
7548 nick,
7549 &pending,
7550 &our_relay,
7551 &our_slot_id,
7552 &our_slot_token,
7553 as_json,
7554 )
7555}
7556
7557fn cmd_pair_list_inbound(as_json: bool) -> Result<()> {
7560 let items = crate::pending_inbound_pair::list_pending_inbound()?;
7561 if as_json {
7562 println!("{}", serde_json::to_string(&items)?);
7563 return Ok(());
7564 }
7565 if items.is_empty() {
7566 println!("no pending pair requests — your inbox is clear.");
7567 return Ok(());
7568 }
7569 let plural = if items.len() == 1 { "" } else { "s" };
7576 println!("{} pending pair request{plural}:\n", items.len());
7577 for p in &items {
7578 let ch = crate::character::Character::from_did(&p.peer_did);
7579 let glyph = crate::character::emoji_with_fallback(&ch);
7580 println!(
7583 " {glyph} {nick} ({handle}) wants to pair with you",
7584 nick = ch.nickname,
7585 handle = p.peer_handle,
7586 );
7587 }
7588 println!();
7589 println!(
7590 "→ to accept any: `wire accept <name>` (e.g. `wire accept {first}`)",
7591 first = items
7592 .first()
7593 .map(|p| {
7594 let ch = crate::character::Character::from_did(&p.peer_did);
7595 ch.nickname
7596 })
7597 .unwrap_or_else(|| "<name>".to_string())
7598 );
7599 println!("→ to refuse: `wire reject <name>`");
7600 Ok(())
7601}
7602
7603fn cmd_pair_reject(peer_nick: &str, as_json: bool) -> Result<()> {
7607 let nick = crate::agent_card::bare_handle(peer_nick);
7608 let existed = crate::pending_inbound_pair::read_pending_inbound(nick)?;
7609 crate::pending_inbound_pair::consume_pending_inbound(nick)?;
7610
7611 if as_json {
7612 println!(
7613 "{}",
7614 serde_json::to_string(&json!({
7615 "peer": nick,
7616 "rejected": existed.is_some(),
7617 "had_pending": existed.is_some(),
7618 }))?
7619 );
7620 } else if existed.is_some() {
7621 println!(
7622 "→ rejected pending pair from {nick}\n→ pending-inbound record deleted; no ack sent."
7623 );
7624 } else {
7625 println!("no pending pair from {nick} — nothing to reject");
7626 }
7627 Ok(())
7628}
7629
7630fn cmd_group(cmd: GroupCommand) -> Result<()> {
7641 match cmd {
7642 GroupCommand::Create { name, json } => cmd_group_create(&name, json),
7643 GroupCommand::Add { group, peer, json } => cmd_group_add(&group, &peer, json),
7644 GroupCommand::Send {
7645 group,
7646 message,
7647 json,
7648 } => cmd_group_send(&group, &message, json),
7649 GroupCommand::Tail { group, limit, json } => cmd_group_tail(&group, limit, json),
7650 GroupCommand::List { json } => cmd_group_list(json),
7651 GroupCommand::Invite { group, json } => cmd_group_invite(&group, json),
7652 GroupCommand::Join { code, json } => cmd_group_join(&code, json),
7653 }
7654}
7655
7656fn group_self() -> Result<(String, String, String, String)> {
7659 let card = config::read_agent_card()?;
7660 let did = card
7661 .get("did")
7662 .and_then(Value::as_str)
7663 .ok_or_else(|| anyhow!("agent-card missing did — run `wire up` first"))?
7664 .to_string();
7665 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
7666 let pk_b64 = card
7667 .get("verify_keys")
7668 .and_then(Value::as_object)
7669 .and_then(|m| m.values().next())
7670 .and_then(|v| v.get("key"))
7671 .and_then(Value::as_str)
7672 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?
7673 .to_string();
7674 let pk_bytes = crate::signing::b64decode(&pk_b64)?;
7675 let key_id = make_key_id(&handle, &pk_bytes);
7676 Ok((did, handle, key_id, pk_b64))
7677}
7678
7679fn group_room_relay_url() -> Result<String> {
7682 use crate::endpoints::EndpointScope;
7683 let state = config::read_relay_state()?;
7684 let eps = crate::endpoints::self_endpoints(&state);
7685 let pick = eps
7686 .iter()
7687 .find(|e| e.scope == EndpointScope::Federation)
7688 .or_else(|| eps.iter().find(|e| e.scope == EndpointScope::Lan))
7689 .or_else(|| eps.iter().find(|e| e.scope == EndpointScope::Local))
7690 .or_else(|| eps.first());
7691 match pick {
7692 Some(e) if !e.relay_url.is_empty() => Ok(e.relay_url.clone()),
7693 _ => bail!("no relay endpoint on this identity — run `wire up --relay <url>` first"),
7694 }
7695}
7696
7697fn distribute_group_invite(group: &crate::group::Group, self_did: &str) -> Result<usize> {
7701 let (_, self_handle, _, pk_b64) = group_self()?;
7702 let sk_seed = config::read_private_key()?;
7703 let pk_bytes = crate::signing::b64decode(&pk_b64)?;
7704 let now_iso = time::OffsetDateTime::now_utc()
7705 .format(&time::format_description::well_known::Rfc3339)
7706 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
7707 let group_json = serde_json::to_value(group)?;
7708 let mut delivered = 0usize;
7709 for handle in group.other_member_handles(self_did) {
7710 let event = json!({
7711 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
7712 "timestamp": now_iso,
7713 "from": self_did,
7714 "to": format!("did:wire:{handle}"),
7715 "type": "group_invite",
7716 "kind": parse_kind("group_invite")?,
7717 "body": group_json,
7718 });
7719 let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &self_handle)
7720 .map_err(|e| anyhow!("signing group_invite for `{handle}`: {e:?}"))?;
7721 let line = serde_json::to_vec(&signed)?;
7722 if config::append_outbox_record(&handle, &line).is_ok() {
7723 delivered += 1;
7724 }
7725 }
7726 Ok(delivered)
7727}
7728
7729fn introduce_pin(
7736 trust: &mut Value,
7737 handle: &str,
7738 did: &str,
7739 key_id: &str,
7740 key: &str,
7741 group_id: &str,
7742) -> bool {
7743 let now = time::OffsetDateTime::now_utc()
7744 .format(&time::format_description::well_known::Rfc3339)
7745 .unwrap_or_default();
7746 let agents = trust
7747 .as_object_mut()
7748 .expect("trust is an object")
7749 .entry("agents")
7750 .or_insert_with(|| json!({}));
7751 let key_rec = json!({"key_id": key_id, "key": key, "added_at": now, "active": true});
7752 match agents.get_mut(handle) {
7753 Some(existing) => {
7754 let keys = existing
7757 .as_object_mut()
7758 .and_then(|o| o.get_mut("public_keys"))
7759 .and_then(Value::as_array_mut);
7760 if let Some(keys) = keys {
7761 let have = keys
7762 .iter()
7763 .any(|k| k.get("key_id").and_then(Value::as_str) == Some(key_id));
7764 if !have {
7765 keys.push(key_rec);
7766 return true;
7767 }
7768 }
7769 false
7770 }
7771 None => {
7772 agents[handle] = json!({
7774 "tier": "UNTRUSTED",
7775 "did": did,
7776 "public_keys": [key_rec],
7777 "introduced_via": group_id,
7778 "pinned_at": now,
7779 });
7780 true
7781 }
7782 }
7783}
7784
7785fn ingest_group_invites() -> Result<()> {
7791 let inbox = config::inbox_dir()?;
7792 if !inbox.exists() {
7793 return Ok(());
7794 }
7795 let (self_did, ..) = group_self()?;
7796 let trust_now = config::read_trust().unwrap_or_else(|_| json!({"agents": {}}));
7797 let mut best: std::collections::HashMap<String, crate::group::Group> =
7799 std::collections::HashMap::new();
7800
7801 for entry in std::fs::read_dir(&inbox)?.flatten() {
7802 let path = entry.path();
7803 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
7804 continue;
7805 }
7806 for line in std::fs::read_to_string(&path).unwrap_or_default().lines() {
7807 let event: Value = match serde_json::from_str(line) {
7808 Ok(v) => v,
7809 Err(_) => continue,
7810 };
7811 if event.get("type").and_then(Value::as_str) != Some("group_invite") {
7812 continue;
7813 }
7814 if verify_message_v31(&event, &trust_now).is_err() {
7817 continue;
7818 }
7819 let Some(body) = event.get("body") else {
7820 continue;
7821 };
7822 let group: crate::group::Group = match serde_json::from_value(body.clone()) {
7823 Ok(g) => g,
7824 Err(_) => continue,
7825 };
7826 if group.creator_did == self_did {
7827 continue; }
7829 let from_did = event.get("from").and_then(Value::as_str).unwrap_or("");
7831 if from_did != group.creator_did {
7832 continue;
7833 }
7834 let creator_handle = crate::agent_card::display_handle_from_did(&group.creator_did);
7837 let creator_key = trust_now
7838 .get("agents")
7839 .and_then(|a| a.get(creator_handle))
7840 .and_then(|a| a.get("public_keys"))
7841 .and_then(Value::as_array)
7842 .and_then(|ks| ks.first())
7843 .and_then(|k| k.get("key"))
7844 .and_then(Value::as_str)
7845 .and_then(|b| crate::signing::b64decode(b).ok());
7846 let Some(creator_key) = creator_key else {
7847 continue;
7848 };
7849 if !group.verify(&creator_key) {
7850 continue;
7851 }
7852 match best.get(&group.id) {
7853 Some(prev) if prev.epoch >= group.epoch => {}
7854 _ => {
7855 best.insert(group.id.clone(), group);
7856 }
7857 }
7858 }
7859 }
7860
7861 if best.is_empty() {
7862 return Ok(());
7863 }
7864 let mut trust = config::read_trust()?;
7865 for group in best.values() {
7866 if let Ok(local) = crate::group::load_group(&group.id)
7868 && local.epoch >= group.epoch
7869 {
7870 continue;
7871 }
7872 crate::group::save_group(group)?;
7873 for m in &group.members {
7874 if m.did == self_did || m.key.is_empty() {
7875 continue;
7876 }
7877 introduce_pin(&mut trust, &m.handle, &m.did, &m.key_id, &m.key, &group.id);
7878 }
7879 }
7880 config::write_trust(&trust)?;
7881 Ok(())
7882}
7883
7884fn cmd_group_create(name: &str, as_json: bool) -> Result<()> {
7885 if !config::is_initialized()? {
7886 bail!("not initialized — run `wire up` first");
7887 }
7888 let (did, handle, key_id, pk_b64) = group_self()?;
7889 let relay_url = group_room_relay_url()?;
7890 let client = crate::relay_client::RelayClient::new(&relay_url);
7892 let room = client
7893 .allocate_slot(Some(&format!("group:{name}")))
7894 .with_context(|| format!("allocating group room on {relay_url}"))?;
7895 let id = format!("g{:016x}", rand::random::<u64>());
7896 let mut group = crate::group::Group::new(id.clone(), name.to_string(), handle, did.clone());
7897 group.set_room(relay_url, room.slot_id, room.slot_token);
7898 group.set_member_keys(&did, key_id, pk_b64)?;
7899 let sk = config::read_private_key()?;
7900 group.sign(&sk)?;
7901 crate::group::save_group(&group)?;
7902 if as_json {
7903 println!(
7904 "{}",
7905 serde_json::to_string(&json!({
7906 "id": id, "name": name, "members": 1, "relay_url": group.relay_url
7907 }))?
7908 );
7909 } else {
7910 println!(
7911 "created group `{name}` (id {id}) — room on {}. You are the creator.",
7912 group.relay_url
7913 );
7914 println!(" add peers: `wire group add {id} <peer>` talk: `wire group send {id} \"hi\"`");
7915 }
7916 Ok(())
7917}
7918
7919fn cmd_group_add(group_ref: &str, peer: &str, as_json: bool) -> Result<()> {
7920 let (self_did, ..) = group_self()?;
7921 let mut group = crate::group::resolve_group(group_ref)?;
7922 if group.creator_did != self_did {
7923 bail!("only the group creator can add members (the creator signs the roster)");
7924 }
7925 let bare = crate::agent_card::bare_handle(peer).to_string();
7927 let trust = config::read_trust()?;
7928 let agent = trust
7929 .get("agents")
7930 .and_then(|a| a.get(&bare))
7931 .ok_or_else(|| {
7932 anyhow!("`{bare}` is not a pinned peer — pair first (`wire dial {bare}@<relay>`)")
7933 })?;
7934 let tier = agent
7935 .get("tier")
7936 .and_then(Value::as_str)
7937 .unwrap_or("UNTRUSTED");
7938 if tier != "VERIFIED" {
7939 bail!(
7940 "`{bare}` is {tier}, not VERIFIED — only verified peers can be added as Members (T22 consent)"
7941 );
7942 }
7943 let peer_did = agent
7944 .get("did")
7945 .and_then(Value::as_str)
7946 .ok_or_else(|| anyhow!("trust entry for `{bare}` is missing a did"))?
7947 .to_string();
7948 let key = agent
7951 .get("public_keys")
7952 .and_then(Value::as_array)
7953 .and_then(|ks| {
7954 ks.iter()
7955 .find(|k| k.get("active").and_then(Value::as_bool).unwrap_or(true))
7956 })
7957 .ok_or_else(|| anyhow!("no active pinned key for `{bare}` in trust"))?;
7958 let peer_key_id = key
7959 .get("key_id")
7960 .and_then(Value::as_str)
7961 .unwrap_or_default()
7962 .to_string();
7963 let peer_pk = key
7964 .get("key")
7965 .and_then(Value::as_str)
7966 .unwrap_or_default()
7967 .to_string();
7968
7969 group.add_member(
7970 bare.clone(),
7971 peer_did.clone(),
7972 crate::group::GroupTier::Member,
7973 )?;
7974 group.set_member_keys(&peer_did, peer_key_id, peer_pk)?;
7975 let sk = config::read_private_key()?;
7976 group.sign(&sk)?;
7977 crate::group::save_group(&group)?;
7978 let delivered = distribute_group_invite(&group, &self_did).unwrap_or(0);
7981 if as_json {
7982 println!(
7983 "{}",
7984 serde_json::to_string(&json!({
7985 "group": group.id, "added": bare, "epoch": group.epoch,
7986 "members": group.members.len(), "invites_queued": delivered
7987 }))?
7988 );
7989 } else {
7990 println!(
7991 "added `{bare}` to `{}` — now {} member(s), epoch {} ({delivered} invite(s) queued; run `wire push`)",
7992 group.name,
7993 group.members.len(),
7994 group.epoch
7995 );
7996 }
7997 Ok(())
7998}
7999
8000fn cmd_group_send(group_ref: &str, message: &str, as_json: bool) -> Result<()> {
8001 if !config::is_initialized()? {
8002 bail!("not initialized — run `wire up` first");
8003 }
8004 ingest_group_invites()?;
8005 let (self_did, self_handle, _, pk_b64) = group_self()?;
8006 let group = crate::group::resolve_group(group_ref)?;
8007 if group.slot_id.is_empty() || group.relay_url.is_empty() {
8012 bail!(
8013 "group `{}` has no room slot (legacy/partial group)",
8014 group.name
8015 );
8016 }
8017 let sk_seed = config::read_private_key()?;
8018 let pk_bytes = crate::signing::b64decode(&pk_b64)?;
8019 let now_iso = time::OffsetDateTime::now_utc()
8020 .format(&time::format_description::well_known::Rfc3339)
8021 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
8022 let event = json!({
8023 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
8024 "timestamp": now_iso,
8025 "from": self_did,
8026 "to": format!("did:wire:group:{}", group.id),
8027 "type": "group_msg",
8028 "kind": parse_kind("group_msg")?,
8029 "body": {
8030 "group_id": group.id,
8031 "group_name": group.name,
8032 "epoch": group.epoch,
8033 "text": message,
8034 },
8035 });
8036 let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &self_handle)
8037 .map_err(|e| anyhow!("signing group_msg: {e:?}"))?;
8038 let client = crate::relay_client::RelayClient::new(&group.relay_url);
8040 client
8041 .post_event(&group.slot_id, &group.slot_token, &signed)
8042 .with_context(|| {
8043 format!(
8044 "posting to group room {} on {}",
8045 group.slot_id, group.relay_url
8046 )
8047 })?;
8048 if as_json {
8049 println!(
8050 "{}",
8051 serde_json::to_string(&json!({
8052 "group": group.id, "epoch": group.epoch, "status": "posted",
8053 "members": group.members.len()
8054 }))?
8055 );
8056 } else {
8057 println!(
8058 "group `{}`: posted to the room ({} member(s))",
8059 group.name,
8060 group.members.len()
8061 );
8062 }
8063 Ok(())
8064}
8065
8066fn cmd_group_tail(group_ref: &str, limit: usize, as_json: bool) -> Result<()> {
8067 ingest_group_invites()?;
8068 let group = crate::group::resolve_group(group_ref)?;
8069 if group.slot_id.is_empty() || group.relay_url.is_empty() {
8070 bail!(
8071 "group `{}` has no room slot (legacy/partial group)",
8072 group.name
8073 );
8074 }
8075 let mut trust = config::read_trust().unwrap_or_else(|_| json!({"agents": {}}));
8076 let client = crate::relay_client::RelayClient::new(&group.relay_url);
8077 let fetch = if limit == 0 {
8079 1000
8080 } else {
8081 (limit * 4).min(1000)
8082 };
8083 let events = client
8084 .list_events(&group.slot_id, &group.slot_token, None, Some(fetch))
8085 .with_context(|| {
8086 format!(
8087 "pulling group room {} on {}",
8088 group.slot_id, group.relay_url
8089 )
8090 })?;
8091
8092 let mut trust_changed = false;
8098 for event in &events {
8099 if event.get("type").and_then(Value::as_str) != Some("group_join") {
8100 continue;
8101 }
8102 if let Some((h, did, kid, key)) = group_join_pin_material(event)
8103 && introduce_pin(&mut trust, &h, &did, &kid, &key, &group.id)
8104 {
8105 trust_changed = true;
8106 }
8107 }
8108 if trust_changed {
8109 let _ = config::write_trust(&trust);
8110 }
8111
8112 enum Line {
8115 Msg {
8116 from: String,
8117 text: String,
8118 verified: bool,
8119 },
8120 Join {
8121 who: String,
8122 },
8123 }
8124 let mut timeline: Vec<(String, Line)> = Vec::new();
8125 for event in &events {
8126 let ty = event.get("type").and_then(Value::as_str).unwrap_or("");
8127 let body = match event.get("body") {
8128 Some(Value::String(s)) => serde_json::from_str::<Value>(s).ok(),
8129 Some(v) => Some(v.clone()),
8130 None => None,
8131 };
8132 let Some(body) = body else { continue };
8133 if body.get("group_id").and_then(Value::as_str) != Some(group.id.as_str()) {
8134 continue;
8135 }
8136 let ts = event
8137 .get("timestamp")
8138 .and_then(Value::as_str)
8139 .unwrap_or("")
8140 .to_string();
8141 let from_did = event.get("from").and_then(Value::as_str).unwrap_or("");
8142 let from_handle = crate::agent_card::display_handle_from_did(from_did).to_string();
8143 match ty {
8144 "group_msg" => {
8145 let text = body
8146 .get("text")
8147 .and_then(Value::as_str)
8148 .unwrap_or("")
8149 .to_string();
8150 let verified = verify_message_v31(event, &trust).is_ok();
8151 timeline.push((
8152 ts,
8153 Line::Msg {
8154 from: from_handle,
8155 text,
8156 verified,
8157 },
8158 ));
8159 }
8160 "group_join" => timeline.push((ts, Line::Join { who: from_handle })),
8161 _ => {}
8162 }
8163 }
8164 timeline.sort_by(|a, b| a.0.cmp(&b.0));
8165 let start = if limit > 0 {
8166 timeline.len().saturating_sub(limit)
8167 } else {
8168 0
8169 };
8170 let recent = &timeline[start..];
8171 if as_json {
8172 let arr: Vec<Value> = recent
8173 .iter()
8174 .map(|(ts, l)| match l {
8175 Line::Msg {
8176 from,
8177 text,
8178 verified,
8179 } => {
8180 json!({"ts": ts, "type": "msg", "from": from, "text": text, "verified": verified})
8181 }
8182 Line::Join { who } => json!({"ts": ts, "type": "join", "from": who}),
8183 })
8184 .collect();
8185 println!(
8186 "{}",
8187 serde_json::to_string(
8188 &json!({"group": group.id, "name": group.name, "messages": arr})
8189 )?
8190 );
8191 } else if recent.is_empty() {
8192 println!("group `{}`: no messages yet", group.name);
8193 } else {
8194 for (ts, l) in recent {
8195 let short_ts: String = ts.chars().take(19).collect();
8196 match l {
8197 Line::Msg {
8198 from,
8199 text,
8200 verified,
8201 } => {
8202 let mark = if *verified { "✓" } else { "✗" };
8203 println!("[{short_ts}] {} {mark}: {text}", persona_label(from));
8204 }
8205 Line::Join { who } => println!("[{short_ts}] {} joined", persona_label(who)),
8206 }
8207 }
8208 }
8209 Ok(())
8210}
8211
8212fn group_join_pin_material(event: &Value) -> Option<(String, String, String, String)> {
8218 let body = match event.get("body") {
8219 Some(Value::String(s)) => serde_json::from_str::<Value>(s).ok()?,
8220 Some(v) => v.clone(),
8221 None => return None,
8222 };
8223 let card = body.get("joiner_card")?;
8224 let mut tmp = json!({"agents": {}});
8226 crate::trust::add_agent_card_pin(&mut tmp, card, Some("UNTRUSTED"));
8227 if verify_message_v31(event, &tmp).is_err() {
8228 return None;
8229 }
8230 let did = card.get("did").and_then(Value::as_str)?.to_string();
8231 let handle = card
8232 .get("handle")
8233 .and_then(Value::as_str)
8234 .map(str::to_string)
8235 .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
8236 let (kid_full, krec) = card
8237 .get("verify_keys")
8238 .and_then(Value::as_object)
8239 .and_then(|m| m.iter().next())?;
8240 let key_id = kid_full
8241 .strip_prefix("ed25519:")
8242 .unwrap_or(kid_full)
8243 .to_string();
8244 let key = krec.get("key").and_then(Value::as_str)?.to_string();
8245 Some((handle, did, key_id, key))
8246}
8247
8248fn cmd_group_invite(group_ref: &str, as_json: bool) -> Result<()> {
8251 let group = crate::group::resolve_group(group_ref)?;
8252 if group.slot_id.is_empty() || group.relay_url.is_empty() {
8253 bail!(
8254 "group `{}` has no room slot — nothing to invite into",
8255 group.name
8256 );
8257 }
8258 if group.creator_sig.is_empty() {
8259 bail!(
8260 "group `{}` roster is unsigned — add a member or recreate before inviting",
8261 group.name
8262 );
8263 }
8264 let payload = serde_json::to_vec(&group)?;
8265 let code = format!("wire-group:{}", crate::signing::b64encode(&payload));
8266 if as_json {
8267 println!(
8268 "{}",
8269 serde_json::to_string(&json!({"group": group.id, "name": group.name, "code": code}))?
8270 );
8271 } else {
8272 println!(
8273 "join code for `{}` — share ONLY with people you want in the room (it IS the room key):\n",
8274 group.name
8275 );
8276 println!("{code}\n");
8277 println!("they run: wire group join <code>");
8278 }
8279 Ok(())
8280}
8281
8282fn cmd_group_join(code: &str, as_json: bool) -> Result<()> {
8286 if !config::is_initialized()? {
8287 bail!("not initialized — run `wire up` first");
8288 }
8289 let raw = code.trim();
8290 let b64 = raw.strip_prefix("wire-group:").unwrap_or(raw);
8291 let payload =
8292 crate::signing::b64decode(b64).map_err(|_| anyhow!("invalid join code (not base64)"))?;
8293 let group: crate::group::Group = serde_json::from_slice(&payload)
8294 .map_err(|_| anyhow!("invalid join code (not a group payload)"))?;
8295 if group.slot_id.is_empty() || group.relay_url.is_empty() {
8296 bail!("join code carries no room coords");
8297 }
8298 let creator_key = group
8301 .members
8302 .iter()
8303 .find(|m| m.did == group.creator_did)
8304 .map(|m| m.key.clone())
8305 .filter(|k| !k.is_empty())
8306 .and_then(|k| crate::signing::b64decode(&k).ok())
8307 .ok_or_else(|| anyhow!("join code is missing the creator's key"))?;
8308 if !group.verify(&creator_key) {
8309 bail!("join code failed its signature check (tampered or corrupt)");
8310 }
8311 let (self_did, self_handle, _, _) = group_self()?;
8312 if group.creator_did == self_did {
8313 bail!("you created group `{}` — you're already in it", group.name);
8314 }
8315
8316 crate::group::save_group(&group)?;
8318 let mut trust = config::read_trust()?;
8319 for m in &group.members {
8320 if m.did == self_did || m.key.is_empty() {
8321 continue;
8322 }
8323 introduce_pin(&mut trust, &m.handle, &m.did, &m.key_id, &m.key, &group.id);
8324 }
8325 config::write_trust(&trust)?;
8326
8327 let card = config::read_agent_card()?;
8329 let sk_seed = config::read_private_key()?;
8330 let pk_b64 = card
8331 .get("verify_keys")
8332 .and_then(Value::as_object)
8333 .and_then(|m| m.values().next())
8334 .and_then(|v| v.get("key"))
8335 .and_then(Value::as_str)
8336 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
8337 let pk_bytes = crate::signing::b64decode(pk_b64)?;
8338 let now_iso = time::OffsetDateTime::now_utc()
8339 .format(&time::format_description::well_known::Rfc3339)
8340 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
8341 let event = json!({
8342 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
8343 "timestamp": now_iso,
8344 "from": self_did,
8345 "to": format!("did:wire:group:{}", group.id),
8346 "type": "group_join",
8347 "kind": parse_kind("group_join")?,
8348 "body": {
8349 "group_id": group.id,
8350 "group_name": group.name,
8351 "epoch": group.epoch,
8352 "joiner_card": card,
8353 "text": "joined",
8354 },
8355 });
8356 let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &self_handle)
8357 .map_err(|e| anyhow!("signing group_join: {e:?}"))?;
8358 let client = crate::relay_client::RelayClient::new(&group.relay_url);
8359 let announced = client
8360 .post_event(&group.slot_id, &group.slot_token, &signed)
8361 .is_ok();
8362
8363 if as_json {
8364 println!(
8365 "{}",
8366 serde_json::to_string(&json!({
8367 "group": group.id, "name": group.name, "joined": true,
8368 "members": group.members.len(), "announced": announced
8369 }))?
8370 );
8371 } else {
8372 println!(
8373 "joined group `{}` ({} member(s)) at Introduced tier.",
8374 group.name,
8375 group.members.len()
8376 );
8377 if announced {
8378 println!(" announced to the room — members will verify your messages.");
8379 } else {
8380 println!(
8381 " ⚠ couldn't reach the room relay to announce; retry a `wire group send` so members can verify you."
8382 );
8383 }
8384 println!(
8385 " read: `wire group tail {}` talk: `wire group send {} \"hi\"`",
8386 group.id, group.id
8387 );
8388 }
8389 Ok(())
8390}
8391
8392fn cmd_group_list(as_json: bool) -> Result<()> {
8393 let groups = crate::group::list_groups()?;
8394 if as_json {
8395 let arr: Vec<Value> = groups
8396 .iter()
8397 .map(|g| {
8398 json!({
8399 "id": g.id,
8400 "name": g.name,
8401 "epoch": g.epoch,
8402 "members": g.members.iter().map(|m| json!({"handle": m.handle, "tier": m.tier.as_str()})).collect::<Vec<_>>(),
8403 })
8404 })
8405 .collect();
8406 println!("{}", serde_json::to_string(&json!({"groups": arr}))?);
8407 } else if groups.is_empty() {
8408 println!("no groups yet — create one with `wire group create <name>`");
8409 } else {
8410 for g in &groups {
8411 println!(
8412 "{} ({}) — {} member(s), epoch {}",
8413 g.name,
8414 g.id,
8415 g.members.len(),
8416 g.epoch
8417 );
8418 for m in &g.members {
8419 println!(" {} [{}]", m.handle, m.tier.as_str());
8420 }
8421 }
8422 }
8423 Ok(())
8424}
8425
8426fn cmd_mesh(cmd: MeshCommand) -> Result<()> {
8429 match cmd {
8430 MeshCommand::Status { stale_secs, json } => cmd_session_mesh_status(stale_secs, json),
8431 MeshCommand::Broadcast {
8432 kind,
8433 scope,
8434 exclude,
8435 noreply,
8436 body,
8437 json,
8438 } => cmd_mesh_broadcast(&kind, &scope, &exclude, noreply, &body, json),
8439 MeshCommand::Role { action } => cmd_mesh_role(action),
8440 MeshCommand::Route {
8441 role,
8442 strategy,
8443 exclude,
8444 kind,
8445 body,
8446 json,
8447 } => cmd_mesh_route(&role, &strategy, &exclude, &kind, &body, json),
8448 }
8449}
8450
8451fn cmd_mesh_route(
8456 role: &str,
8457 strategy: &str,
8458 exclude: &[String],
8459 kind: &str,
8460 body_arg: &str,
8461 as_json: bool,
8462) -> Result<()> {
8463 use std::time::Instant;
8464
8465 if !config::is_initialized()? {
8466 bail!("not initialized — run `wire init <handle>` first");
8467 }
8468 let strategy = strategy.to_ascii_lowercase();
8469 if !matches!(strategy.as_str(), "round-robin" | "first" | "random") {
8470 bail!("unknown strategy `{strategy}` — use round-robin | first | random");
8471 }
8472
8473 let state = config::read_relay_state()?;
8476 let pinned: std::collections::BTreeSet<String> = state["peers"]
8477 .as_object()
8478 .map(|m| m.keys().cloned().collect())
8479 .unwrap_or_default();
8480
8481 let exclude_set: std::collections::HashSet<&str> = exclude.iter().map(String::as_str).collect();
8482
8483 let sessions = crate::session::list_sessions()?;
8488 let mut candidates: Vec<(String, Option<String>)> = Vec::new(); for s in &sessions {
8490 let handle = match s.handle.as_ref() {
8491 Some(h) => h.clone(),
8492 None => continue,
8493 };
8494 if exclude_set.contains(handle.as_str()) {
8495 continue;
8496 }
8497 if !pinned.contains(&handle) {
8498 continue;
8499 }
8500 let card_path = s
8501 .home_dir
8502 .join("config")
8503 .join("wire")
8504 .join("agent-card.json");
8505 let card_role = std::fs::read(&card_path)
8506 .ok()
8507 .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
8508 .and_then(|c| {
8509 c.get("profile")
8510 .and_then(|p| p.get("role"))
8511 .and_then(Value::as_str)
8512 .map(str::to_string)
8513 });
8514 if card_role.as_deref() == Some(role) {
8515 candidates.push((handle, s.did.clone()));
8516 }
8517 }
8518
8519 candidates.sort_by(|a, b| a.0.cmp(&b.0));
8520 candidates.dedup_by(|a, b| a.0 == b.0);
8521
8522 if candidates.is_empty() {
8523 bail!(
8524 "no pinned sister with role=`{role}` (run `wire mesh role list` to see what's available)"
8525 );
8526 }
8527
8528 let chosen = match strategy.as_str() {
8529 "first" => candidates[0].clone(),
8530 "random" => {
8531 use rand::Rng;
8532 let idx = rand::thread_rng().gen_range(0..candidates.len());
8533 candidates[idx].clone()
8534 }
8535 "round-robin" => {
8536 let cursor_path = mesh_route_cursor_path()?;
8541 let mut cursors: std::collections::BTreeMap<String, String> =
8542 read_mesh_route_cursors(&cursor_path);
8543 let last = cursors.get(role).cloned();
8544 let pick = match last {
8545 None => candidates[0].clone(),
8546 Some(last_h) => candidates
8547 .iter()
8548 .find(|(h, _)| h.as_str() > last_h.as_str())
8549 .cloned()
8550 .unwrap_or_else(|| candidates[0].clone()),
8551 };
8552 cursors.insert(role.to_string(), pick.0.clone());
8553 write_mesh_route_cursors(&cursor_path, &cursors)?;
8554 pick
8555 }
8556 _ => unreachable!(),
8557 };
8558
8559 let (chosen_handle, _chosen_did) = chosen;
8560
8561 let body_value: Value = if body_arg == "-" {
8563 use std::io::Read;
8564 let mut raw = String::new();
8565 std::io::stdin()
8566 .read_to_string(&mut raw)
8567 .with_context(|| "reading body from stdin")?;
8568 serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
8569 } else if let Some(path) = body_arg.strip_prefix('@') {
8570 let raw =
8571 std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
8572 serde_json::from_str(&raw).unwrap_or(Value::String(raw))
8573 } else {
8574 Value::String(body_arg.to_string())
8575 };
8576
8577 let sk_seed = config::read_private_key()?;
8578 let card = config::read_agent_card()?;
8579 let did = card
8580 .get("did")
8581 .and_then(Value::as_str)
8582 .ok_or_else(|| anyhow!("agent-card missing did"))?
8583 .to_string();
8584 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
8585 let pk_b64 = card
8586 .get("verify_keys")
8587 .and_then(Value::as_object)
8588 .and_then(|m| m.values().next())
8589 .and_then(|v| v.get("key"))
8590 .and_then(Value::as_str)
8591 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
8592 let pk_bytes = crate::signing::b64decode(pk_b64)?;
8593
8594 let kind_id = parse_kind(kind)?;
8595 let now_iso = time::OffsetDateTime::now_utc()
8596 .format(&time::format_description::well_known::Rfc3339)
8597 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
8598
8599 let event = json!({
8600 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
8601 "timestamp": now_iso,
8602 "from": did,
8603 "to": format!("did:wire:{chosen_handle}"),
8604 "type": kind,
8605 "kind": kind_id,
8606 "body": json!({
8607 "content": body_value,
8608 "routed_via": {
8609 "role": role,
8610 "strategy": strategy,
8611 },
8612 }),
8613 });
8614 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)
8615 .map_err(|e| anyhow!("sign_message_v31 failed: {e:?}"))?;
8616 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
8617
8618 let line = serde_json::to_vec(&signed)?;
8619 config::append_outbox_record(&chosen_handle, &line)?;
8620
8621 let endpoints = crate::endpoints::peer_endpoints_in_priority_order(&state, &chosen_handle);
8622 if endpoints.is_empty() {
8623 bail!(
8624 "no reachable endpoint pinned for `{chosen_handle}` (the role matched, but we can't push)"
8625 );
8626 }
8627 let start = Instant::now();
8628 let mut delivered = false;
8629 let mut last_err: Option<String> = None;
8630 let mut via_scope: Option<String> = None;
8631 for ep in &endpoints {
8632 match crate::relay_client::post_event_to_endpoint(ep, &signed) {
8637 Ok(_) => {
8638 delivered = true;
8639 via_scope = Some(
8640 match ep.scope {
8641 crate::endpoints::EndpointScope::Local => "local",
8642 crate::endpoints::EndpointScope::Lan => "lan",
8643 crate::endpoints::EndpointScope::Uds => "uds",
8644 crate::endpoints::EndpointScope::Federation => "federation",
8645 }
8646 .to_string(),
8647 );
8648 break;
8649 }
8650 Err(e) => last_err = Some(format!("{e:#}")),
8651 }
8652 }
8653 let rtt_ms = start.elapsed().as_millis() as u64;
8654
8655 let summary = json!({
8656 "role": role,
8657 "strategy": strategy,
8658 "routed_to": chosen_handle,
8659 "event_id": event_id,
8660 "delivered": delivered,
8661 "delivered_via": via_scope,
8662 "rtt_ms": rtt_ms,
8663 "candidates": candidates.iter().map(|(h, _)| h.clone()).collect::<Vec<_>>(),
8664 "error": last_err,
8665 });
8666
8667 if as_json {
8668 println!("{}", serde_json::to_string(&summary)?);
8669 } else if delivered {
8670 let via = via_scope.as_deref().unwrap_or("?");
8671 println!("wire mesh route: {role} → {chosen_handle} ({rtt_ms}ms, {via})");
8672 } else {
8673 let err = last_err.as_deref().unwrap_or("no endpoints reachable");
8674 bail!("delivery to `{chosen_handle}` failed: {err}");
8675 }
8676 Ok(())
8677}
8678
8679fn mesh_route_cursor_path() -> Result<std::path::PathBuf> {
8680 Ok(config::state_dir()?.join("mesh-route-cursor.json"))
8681}
8682
8683fn read_mesh_route_cursors(path: &std::path::Path) -> std::collections::BTreeMap<String, String> {
8684 std::fs::read(path)
8685 .ok()
8686 .and_then(|b| serde_json::from_slice(&b).ok())
8687 .unwrap_or_default()
8688}
8689
8690fn write_mesh_route_cursors(
8691 path: &std::path::Path,
8692 cursors: &std::collections::BTreeMap<String, String>,
8693) -> Result<()> {
8694 if let Some(parent) = path.parent() {
8695 std::fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
8696 }
8697 let body = serde_json::to_vec_pretty(cursors)?;
8698 std::fs::write(path, body).with_context(|| format!("writing {path:?}"))?;
8699 Ok(())
8700}
8701
8702fn cmd_mesh_role(action: MeshRoleAction) -> Result<()> {
8707 match action {
8708 MeshRoleAction::Set { role, json } => {
8709 validate_role_tag(&role)?;
8710 let new_profile =
8711 crate::pair_profile::write_profile_field("role", Value::String(role.clone()))?;
8712 if json {
8713 println!(
8714 "{}",
8715 serde_json::to_string(&json!({
8716 "role": role,
8717 "profile": new_profile,
8718 }))?
8719 );
8720 } else {
8721 println!("self role = {role} (signed into agent-card)");
8722 }
8723 }
8724 MeshRoleAction::Get { peer, json } => {
8725 let (who, role) = match peer.as_deref() {
8726 None => {
8727 let card = config::read_agent_card()?;
8728 let role = card
8729 .get("profile")
8730 .and_then(|p| p.get("role"))
8731 .and_then(Value::as_str)
8732 .map(str::to_string);
8733 let who = card
8734 .get("did")
8735 .and_then(Value::as_str)
8736 .map(|d| crate::agent_card::display_handle_from_did(d).to_string())
8737 .unwrap_or_else(|| "self".to_string());
8738 (who, role)
8739 }
8740 Some(handle) => {
8741 let bare = crate::agent_card::bare_handle(handle).to_string();
8742 let trust = config::read_trust()?;
8743 let role = trust
8744 .get("agents")
8745 .and_then(|a| a.get(&bare))
8746 .and_then(|a| a.get("card"))
8747 .and_then(|c| c.get("profile"))
8748 .and_then(|p| p.get("role"))
8749 .and_then(Value::as_str)
8750 .map(str::to_string);
8751 (bare, role)
8752 }
8753 };
8754 if json {
8755 println!(
8756 "{}",
8757 serde_json::to_string(&json!({
8758 "handle": who,
8759 "role": role,
8760 }))?
8761 );
8762 } else {
8763 match role {
8764 Some(r) => println!("{who}: {r}"),
8765 None => println!("{who}: (unset)"),
8766 }
8767 }
8768 }
8769 MeshRoleAction::List { json } => {
8770 let mut self_did: Option<String> = None;
8771 if let Ok(card) = config::read_agent_card() {
8772 self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
8773 }
8774 let sessions = crate::session::list_sessions()?;
8775 let mut rows: Vec<Value> = Vec::new();
8776 for s in &sessions {
8777 let card_path = s
8778 .home_dir
8779 .join("config")
8780 .join("wire")
8781 .join("agent-card.json");
8782 let role = std::fs::read(&card_path)
8783 .ok()
8784 .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
8785 .and_then(|c| {
8786 c.get("profile")
8787 .and_then(|p| p.get("role"))
8788 .and_then(Value::as_str)
8789 .map(str::to_string)
8790 });
8791 let is_self = match (&self_did, &s.did) {
8792 (Some(a), Some(b)) => a == b,
8793 _ => false,
8794 };
8795 rows.push(json!({
8796 "name": s.name,
8797 "handle": s.handle,
8798 "role": role,
8799 "self": is_self,
8800 }));
8801 }
8802 rows.sort_by(|a, b| {
8803 a["name"]
8804 .as_str()
8805 .unwrap_or("")
8806 .cmp(b["name"].as_str().unwrap_or(""))
8807 });
8808 if json {
8809 println!("{}", serde_json::to_string(&json!({"sessions": rows}))?);
8810 } else if rows.is_empty() {
8811 println!("no sister sessions on this machine.");
8812 } else {
8813 println!("SISTER ROLES (this machine):");
8814 for r in &rows {
8815 let name = r["name"].as_str().unwrap_or("?");
8816 let role = r["role"].as_str().unwrap_or("(unset)");
8817 let marker = if r["self"].as_bool().unwrap_or(false) {
8818 " ← you"
8819 } else {
8820 ""
8821 };
8822 println!(" {name:<24} {role}{marker}");
8823 }
8824 }
8825 }
8826 MeshRoleAction::Clear { json } => {
8827 let new_profile = crate::pair_profile::write_profile_field("role", Value::Null)?;
8828 if json {
8829 println!(
8830 "{}",
8831 serde_json::to_string(&json!({
8832 "cleared": true,
8833 "profile": new_profile,
8834 }))?
8835 );
8836 } else {
8837 println!("self role cleared");
8838 }
8839 }
8840 }
8841 Ok(())
8842}
8843
8844fn validate_role_tag(role: &str) -> Result<()> {
8849 if role.is_empty() {
8850 bail!("role must not be empty (use `wire mesh role --clear` to unset)");
8851 }
8852 if role.len() > 32 {
8853 bail!("role too long ({} chars; max 32)", role.len());
8854 }
8855 for c in role.chars() {
8856 if !(c.is_ascii_alphanumeric() || c == '-' || c == '_') {
8857 bail!("role contains illegal char {c:?} (allowed: A-Z a-z 0-9 - _)");
8858 }
8859 }
8860 Ok(())
8861}
8862
8863fn cmd_mesh_broadcast(
8883 kind: &str,
8884 scope_str: &str,
8885 exclude: &[String],
8886 _noreply: bool,
8887 body_arg: &str,
8888 as_json: bool,
8889) -> Result<()> {
8890 use std::time::Instant;
8891
8892 if !config::is_initialized()? {
8893 bail!("not initialized — run `wire init <handle>` first");
8894 }
8895
8896 let scope = match scope_str {
8897 "local" => crate::endpoints::EndpointScope::Local,
8898 "federation" => crate::endpoints::EndpointScope::Federation,
8899 "both" => {
8900 crate::endpoints::EndpointScope::Local
8904 }
8905 other => bail!("unknown scope `{other}` — use local | federation | both"),
8906 };
8907 let any_scope = scope_str == "both";
8908
8909 let state = config::read_relay_state()?;
8910 let peers = state["peers"].as_object().cloned().unwrap_or_default();
8911 if peers.is_empty() {
8912 bail!("no peers pinned — run `wire accept <invite-url>` or `wire pair-accept` first");
8913 }
8914
8915 let exclude_set: std::collections::HashSet<&str> = exclude.iter().map(String::as_str).collect();
8916
8917 struct Target {
8921 handle: String,
8922 endpoints: Vec<crate::endpoints::Endpoint>,
8923 }
8924 let mut targets: Vec<Target> = Vec::new();
8925 let mut skipped_wrong_scope: Vec<String> = Vec::new();
8926 let mut skipped_excluded: Vec<String> = Vec::new();
8927 for handle in peers.keys() {
8928 if exclude_set.contains(handle.as_str()) {
8929 skipped_excluded.push(handle.clone());
8930 continue;
8931 }
8932 let ordered = crate::endpoints::peer_endpoints_in_priority_order(&state, handle);
8933 let filtered: Vec<crate::endpoints::Endpoint> = ordered
8934 .into_iter()
8935 .filter(|ep| any_scope || ep.scope == scope)
8936 .collect();
8937 if filtered.is_empty() {
8938 skipped_wrong_scope.push(handle.clone());
8939 continue;
8940 }
8941 targets.push(Target {
8942 handle: handle.clone(),
8943 endpoints: filtered,
8944 });
8945 }
8946
8947 if targets.is_empty() {
8948 bail!(
8949 "no peers matched scope=`{scope_str}` after exclude filter ({} excluded, {} wrong-scope)",
8950 skipped_excluded.len(),
8951 skipped_wrong_scope.len()
8952 );
8953 }
8954
8955 let sk_seed = config::read_private_key()?;
8957 let card = config::read_agent_card()?;
8958 let did = card
8959 .get("did")
8960 .and_then(Value::as_str)
8961 .ok_or_else(|| anyhow!("agent-card missing did"))?
8962 .to_string();
8963 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
8964 let pk_b64 = card
8965 .get("verify_keys")
8966 .and_then(Value::as_object)
8967 .and_then(|m| m.values().next())
8968 .and_then(|v| v.get("key"))
8969 .and_then(Value::as_str)
8970 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
8971 let pk_bytes = crate::signing::b64decode(pk_b64)?;
8972
8973 let body_value: Value = if body_arg == "-" {
8974 use std::io::Read;
8975 let mut raw = String::new();
8976 std::io::stdin()
8977 .read_to_string(&mut raw)
8978 .with_context(|| "reading body from stdin")?;
8979 serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
8980 } else if let Some(path) = body_arg.strip_prefix('@') {
8981 let raw =
8982 std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
8983 serde_json::from_str(&raw).unwrap_or(Value::String(raw))
8984 } else {
8985 Value::String(body_arg.to_string())
8986 };
8987
8988 let kind_id = parse_kind(kind)?;
8989 let now_iso = time::OffsetDateTime::now_utc()
8990 .format(&time::format_description::well_known::Rfc3339)
8991 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
8992
8993 let broadcast_id = generate_broadcast_id();
8994 let target_count = targets.len();
8995
8996 let mut signed_per_peer: Vec<(String, Vec<crate::endpoints::Endpoint>, Value, String)> =
9000 Vec::with_capacity(targets.len());
9001 for t in &targets {
9002 let body = json!({
9003 "content": body_value,
9004 "broadcast_id": broadcast_id,
9005 "broadcast_target_count": target_count,
9006 });
9007 let event = json!({
9008 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
9009 "timestamp": now_iso,
9010 "from": did,
9011 "to": format!("did:wire:{}", t.handle),
9012 "type": kind,
9013 "kind": kind_id,
9014 "body": body,
9015 });
9016 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)
9017 .map_err(|e| anyhow!("sign_message_v31 failed for `{}`: {e:?}", t.handle))?;
9018 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
9019 signed_per_peer.push((t.handle.clone(), t.endpoints.clone(), signed, event_id));
9020 }
9021
9022 for (peer, _, signed, _) in &signed_per_peer {
9026 let line = serde_json::to_vec(signed)?;
9027 config::append_outbox_record(peer, &line)?;
9028 }
9029
9030 use std::sync::mpsc;
9034 let (tx, rx) = mpsc::channel::<Value>();
9035 std::thread::scope(|s| {
9036 for (peer, endpoints, signed, event_id) in &signed_per_peer {
9037 let tx = tx.clone();
9038 let peer = peer.clone();
9039 let event_id = event_id.clone();
9040 let endpoints = endpoints.clone();
9041 let signed = signed.clone();
9042 s.spawn(move || {
9043 let start = Instant::now();
9044 let mut delivered = false;
9045 let mut last_err: Option<String> = None;
9046 let mut delivered_via: Option<String> = None;
9047 for ep in &endpoints {
9048 match crate::relay_client::post_event_to_endpoint(ep, &signed) {
9053 Ok(_) => {
9054 delivered = true;
9055 delivered_via = Some(
9056 match ep.scope {
9057 crate::endpoints::EndpointScope::Local => "local",
9058 crate::endpoints::EndpointScope::Lan => "lan",
9059 crate::endpoints::EndpointScope::Uds => "uds",
9060 crate::endpoints::EndpointScope::Federation => "federation",
9061 }
9062 .to_string(),
9063 );
9064 break;
9065 }
9066 Err(e) => last_err = Some(format!("{e:#}")),
9067 }
9068 }
9069 let rtt_ms = start.elapsed().as_millis() as u64;
9070 let _ = tx.send(json!({
9071 "peer": peer,
9072 "event_id": event_id,
9073 "delivered": delivered,
9074 "delivered_via": delivered_via,
9075 "rtt_ms": rtt_ms,
9076 "error": last_err,
9077 }));
9078 });
9079 }
9080 });
9081 drop(tx);
9082
9083 let mut results: Vec<Value> = rx.iter().collect();
9084 results.sort_by(|a, b| {
9085 a["peer"]
9086 .as_str()
9087 .unwrap_or("")
9088 .cmp(b["peer"].as_str().unwrap_or(""))
9089 });
9090
9091 let delivered = results
9092 .iter()
9093 .filter(|r| r["delivered"].as_bool().unwrap_or(false))
9094 .count();
9095 let failed = results.len() - delivered;
9096
9097 let summary = json!({
9098 "broadcast_id": broadcast_id,
9099 "kind": kind,
9100 "scope": scope_str,
9101 "target_count": target_count,
9102 "delivered": delivered,
9103 "failed": failed,
9104 "skipped_excluded": skipped_excluded,
9105 "skipped_wrong_scope": skipped_wrong_scope,
9106 "results": results,
9107 });
9108
9109 if as_json {
9110 println!("{}", serde_json::to_string(&summary)?);
9111 return Ok(());
9112 }
9113
9114 println!("wire mesh broadcast: scope={scope_str} → {target_count} pinned peer(s)");
9115 for r in &results {
9116 let peer = r["peer"].as_str().unwrap_or("?");
9117 let delivered = r["delivered"].as_bool().unwrap_or(false);
9118 let rtt = r["rtt_ms"].as_u64().unwrap_or(0);
9119 let via = r["delivered_via"].as_str().unwrap_or("");
9120 if delivered {
9121 println!(" {peer:<24} ✓ delivered ({rtt}ms, {via})");
9122 } else {
9123 let err = r["error"].as_str().unwrap_or("?");
9124 println!(" {peer:<24} ✗ failed — {err}");
9125 }
9126 }
9127 if !skipped_excluded.is_empty() {
9128 println!(" excluded: {}", skipped_excluded.join(", "));
9129 }
9130 if !skipped_wrong_scope.is_empty() {
9131 println!(
9132 " skipped (wrong scope): {}",
9133 skipped_wrong_scope.join(", ")
9134 );
9135 }
9136 println!("broadcast_id: {broadcast_id}");
9137 Ok(())
9138}
9139
9140fn generate_broadcast_id() -> String {
9144 use rand::RngCore;
9145 let mut buf = [0u8; 16];
9146 rand::thread_rng().fill_bytes(&mut buf);
9147 let h = hex::encode(buf);
9148 format!(
9149 "{}-{}-{}-{}-{}",
9150 &h[0..8],
9151 &h[8..12],
9152 &h[12..16],
9153 &h[16..20],
9154 &h[20..32],
9155 )
9156}
9157
9158fn cmd_session(cmd: SessionCommand) -> Result<()> {
9159 match cmd {
9160 SessionCommand::New {
9161 name,
9162 relay,
9163 with_local,
9164 local_relay,
9165 with_lan,
9166 lan_relay,
9167 with_uds,
9168 uds_socket,
9169 no_daemon,
9170 local_only,
9171 json,
9172 } => cmd_session_new(
9173 name.as_deref(),
9174 &relay,
9175 with_local,
9176 &local_relay,
9177 with_lan,
9178 lan_relay.as_deref(),
9179 with_uds,
9180 uds_socket.as_deref(),
9181 no_daemon,
9182 local_only,
9183 json,
9184 ),
9185 SessionCommand::List { json } => cmd_session_list(json),
9186 SessionCommand::ListLocal { json } => cmd_session_list_local(json),
9187 SessionCommand::PairAllLocal {
9188 settle_secs,
9189 federation_relay,
9190 json,
9191 } => cmd_session_pair_all_local(settle_secs, &federation_relay, json),
9192 SessionCommand::MeshStatus { stale_secs, json } => {
9193 cmd_session_mesh_status(stale_secs, json)
9194 }
9195 SessionCommand::Env { name, json } => cmd_session_env(name.as_deref(), json),
9196 SessionCommand::Current { json } => cmd_session_current(json),
9197 SessionCommand::Bind { name, json } => cmd_session_bind(name.as_deref(), json),
9198 SessionCommand::Destroy { name, force, json } => cmd_session_destroy(&name, force, json),
9199 }
9200}
9201
9202fn cmd_session_bind(name_arg: Option<&str>, json: bool) -> Result<()> {
9203 let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
9204 let cwd_str = crate::session::normalize_cwd_key(&cwd);
9205
9206 let resolved_name = match name_arg {
9207 Some(n) => crate::session::sanitize_name(n),
9208 None => crate::session::sanitize_name(
9209 cwd.file_name()
9210 .and_then(|s| s.to_str())
9211 .ok_or_else(|| anyhow!("cwd has no basename to derive session name from"))?,
9212 ),
9213 };
9214
9215 let session_home = crate::session::session_dir(&resolved_name)?;
9216 if !session_home.exists() {
9217 bail!(
9218 "session `{resolved_name}` does not exist (looked at {}). Create it first with `wire session new {resolved_name}` or pass an existing name.",
9219 session_home.display()
9220 );
9221 }
9222
9223 let prior = crate::session::read_registry()
9224 .ok()
9225 .and_then(|r| r.by_cwd.get(&cwd_str).cloned());
9226 if prior.as_deref() == Some(resolved_name.as_str()) {
9227 if json {
9228 println!(
9229 "{}",
9230 serde_json::to_string(&json!({
9231 "cwd": cwd_str,
9232 "session": resolved_name,
9233 "changed": false,
9234 }))?
9235 );
9236 } else {
9237 println!("cwd `{cwd_str}` already bound to session `{resolved_name}` (no change)");
9238 }
9239 return Ok(());
9240 }
9241 if let Some(prior_name) = &prior {
9242 eprintln!(
9243 "wire session bind: cwd `{cwd_str}` was bound to `{prior_name}`; overwriting with `{resolved_name}`."
9244 );
9245 }
9246
9247 crate::session::update_registry(|reg| {
9248 reg.by_cwd.insert(cwd_str.clone(), resolved_name.clone());
9249 Ok(())
9250 })?;
9251
9252 if json {
9253 println!(
9254 "{}",
9255 serde_json::to_string(&json!({
9256 "cwd": cwd_str,
9257 "session": resolved_name,
9258 "changed": true,
9259 "previous": prior,
9260 }))?
9261 );
9262 } else {
9263 println!("bound cwd `{cwd_str}` → session `{resolved_name}`");
9264 println!("(next `wire` invocation from this cwd will auto-detect into this session)");
9265 }
9266 Ok(())
9267}
9268
9269fn resolve_session_name(name: Option<&str>) -> Result<String> {
9270 if let Some(n) = name {
9271 return Ok(crate::session::sanitize_name(n));
9272 }
9273 let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
9274 let registry = crate::session::read_registry().unwrap_or_default();
9275 Ok(crate::session::derive_name_from_cwd(&cwd, ®istry))
9276}
9277
9278#[allow(clippy::too_many_arguments)] fn cmd_session_new(
9282 name_arg: Option<&str>,
9283 relay: &str,
9284 with_local: bool,
9285 local_relay: &str,
9286 with_lan: bool,
9287 lan_relay: Option<&str>,
9288 with_uds: bool,
9289 uds_socket: Option<&std::path::Path>,
9290 no_daemon: bool,
9291 local_only: bool,
9292 as_json: bool,
9293) -> Result<()> {
9294 let with_local = with_local || local_only;
9297 if with_lan && lan_relay.is_none() {
9299 bail!("--with-lan requires --lan-relay <url> (e.g. http://192.168.1.50:8771)");
9300 }
9301 if with_uds && uds_socket.is_none() {
9303 bail!("--with-uds requires --uds-socket <path> (e.g. /tmp/wire.sock)");
9304 }
9305 let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
9306 let mut registry = crate::session::read_registry().unwrap_or_default();
9307 let name = match name_arg {
9308 Some(n) => crate::session::sanitize_name(n),
9309 None => crate::session::derive_name_from_cwd(&cwd, ®istry),
9310 };
9311 let session_home = crate::session::session_dir(&name)?;
9312
9313 let already_exists = session_home.exists()
9314 && session_home
9315 .join("config")
9316 .join("wire")
9317 .join("agent-card.json")
9318 .exists();
9319 if already_exists {
9320 registry
9324 .by_cwd
9325 .insert(cwd.to_string_lossy().into_owned(), name.clone());
9326 crate::session::write_registry(®istry)?;
9327 let info = render_session_info(&name, &session_home, &cwd)?;
9328 emit_session_new_result(&info, "already_exists", as_json)?;
9329 if !no_daemon {
9330 ensure_session_daemon(&session_home)?;
9331 }
9332 return Ok(());
9333 }
9334
9335 std::fs::create_dir_all(&session_home)
9336 .with_context(|| format!("creating session dir {session_home:?}"))?;
9337
9338 let init_args: Vec<&str> = if local_only {
9347 vec!["init", &name, "--offline"]
9348 } else {
9349 vec!["init", &name, "--relay", relay]
9350 };
9351 let init_status = run_wire_with_home(&session_home, &init_args)?;
9352 if !init_status.success() {
9353 let how = if local_only {
9354 format!("`wire init {name}` (local-only)")
9355 } else {
9356 format!("`wire init {name} --relay {relay}`")
9357 };
9358 bail!("{how} failed inside session dir {session_home:?}");
9359 }
9360
9361 let effective_handle = if local_only {
9366 name.clone()
9367 } else {
9368 let mut claim_attempt = 0u32;
9369 let mut effective = name.clone();
9370 loop {
9371 claim_attempt += 1;
9372 let status =
9373 run_wire_with_home(&session_home, &["claim", &effective, "--relay", relay])?;
9374 if status.success() {
9375 break;
9376 }
9377 if claim_attempt >= 5 {
9378 bail!(
9379 "5 failed attempts to claim a handle on {relay} for session {name}. \
9380 Try `wire session destroy {name} --force` and re-run with a different name, \
9381 or use `--local-only` if you don't need a federation address."
9382 );
9383 }
9384 let attempt_path = cwd.join(format!("__attempt_{claim_attempt}"));
9385 let suffix = crate::session::derive_name_from_cwd(&attempt_path, ®istry);
9386 let token = suffix
9387 .rsplit('-')
9388 .next()
9389 .filter(|t| t.len() == 4)
9390 .map(str::to_string)
9391 .unwrap_or_else(|| format!("{claim_attempt}"));
9392 effective = format!("{name}-{token}");
9393 }
9394 effective
9395 };
9396
9397 registry
9400 .by_cwd
9401 .insert(cwd.to_string_lossy().into_owned(), name.clone());
9402 crate::session::write_registry(®istry)?;
9403
9404 if with_local {
9415 try_allocate_local_slot(&session_home, &effective_handle, relay, local_relay);
9416 if local_only {
9417 let relay_state_path = session_home.join("config").join("wire").join("relay.json");
9422 let state: Value = std::fs::read(&relay_state_path)
9423 .ok()
9424 .and_then(|b| serde_json::from_slice(&b).ok())
9425 .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
9426 let endpoints = crate::endpoints::self_endpoints(&state);
9427 let has_local = endpoints
9428 .iter()
9429 .any(|e| e.scope == crate::endpoints::EndpointScope::Local);
9430 if !has_local {
9431 bail!(
9432 "--local-only requested but local-relay probe at {local_relay} failed — \
9433 ensure the local relay is running (`wire service install --local-relay`), \
9434 then re-run `wire session new {name} --local-only`."
9435 );
9436 }
9437 }
9438 }
9439
9440 if with_lan && let Some(lan_url) = lan_relay {
9444 try_allocate_lan_slot(&session_home, &effective_handle, lan_url);
9445 }
9446 if with_uds && let Some(socket_path) = uds_socket {
9448 try_allocate_uds_slot(&session_home, &effective_handle, socket_path);
9449 }
9450
9451 if !no_daemon {
9452 ensure_session_daemon(&session_home)?;
9453 }
9454
9455 let info = render_session_info(&name, &session_home, &cwd)?;
9456 emit_session_new_result(&info, "created", as_json)
9457}
9458
9459#[cfg(unix)]
9469fn try_allocate_uds_slot(
9470 session_home: &std::path::Path,
9471 handle: &str,
9472 uds_socket: &std::path::Path,
9473) {
9474 let healthz = match crate::relay_client::uds_request(uds_socket, "GET", "/healthz", &[], b"") {
9477 Ok((200, _)) => true,
9478 Ok((status, body)) => {
9479 eprintln!(
9480 "wire session new: UDS relay probe at {uds_socket:?} returned {status} ({}) — not publishing UDS endpoint",
9481 String::from_utf8_lossy(&body)
9482 );
9483 return;
9484 }
9485 Err(e) => {
9486 eprintln!(
9487 "wire session new: UDS relay at {uds_socket:?} unreachable ({e:#}) — \
9488 not publishing UDS endpoint. Start one with `wire relay-server --uds <path>`."
9489 );
9490 return;
9491 }
9492 };
9493 if !healthz {
9494 return;
9495 }
9496
9497 let alloc_body = serde_json::json!({"handle": handle}).to_string();
9499 let (status, body) = match crate::relay_client::uds_request(
9500 uds_socket,
9501 "POST",
9502 "/v1/slot/allocate",
9503 &[("Content-Type", "application/json")],
9504 alloc_body.as_bytes(),
9505 ) {
9506 Ok(r) => r,
9507 Err(e) => {
9508 eprintln!(
9509 "wire session new: UDS relay slot allocation request failed: {e:#} — not publishing UDS endpoint"
9510 );
9511 return;
9512 }
9513 };
9514 if status >= 300 {
9515 eprintln!(
9516 "wire session new: UDS relay slot allocation returned {status} ({}) — not publishing UDS endpoint",
9517 String::from_utf8_lossy(&body)
9518 );
9519 return;
9520 }
9521 let alloc: crate::relay_client::AllocateResponse = match serde_json::from_slice(&body) {
9522 Ok(a) => a,
9523 Err(e) => {
9524 eprintln!("wire session new: UDS relay returned unparseable allocate response: {e:#}");
9525 return;
9526 }
9527 };
9528
9529 let state_path = session_home.join("config").join("wire").join("relay.json");
9530 let mut state: serde_json::Value = std::fs::read(&state_path)
9531 .ok()
9532 .and_then(|b| serde_json::from_slice(&b).ok())
9533 .unwrap_or_else(|| serde_json::json!({}));
9534
9535 let mut endpoints: Vec<crate::endpoints::Endpoint> = state
9536 .get("self")
9537 .and_then(|s| s.get("endpoints"))
9538 .and_then(|e| e.as_array())
9539 .map(|arr| {
9540 arr.iter()
9541 .filter_map(|v| {
9542 serde_json::from_value::<crate::endpoints::Endpoint>(v.clone()).ok()
9543 })
9544 .collect()
9545 })
9546 .unwrap_or_default();
9547 endpoints.push(crate::endpoints::Endpoint::uds(
9548 format!("unix://{}", uds_socket.display()),
9549 alloc.slot_id.clone(),
9550 alloc.slot_token.clone(),
9551 ));
9552
9553 let self_obj = state
9554 .as_object_mut()
9555 .expect("relay_state root is an object")
9556 .entry("self")
9557 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
9558 if !self_obj.is_object() {
9559 *self_obj = serde_json::Value::Object(serde_json::Map::new());
9560 }
9561 if let Some(obj) = self_obj.as_object_mut() {
9562 obj.insert(
9563 "endpoints".into(),
9564 serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
9565 );
9566 }
9567 if let Err(e) = std::fs::write(
9568 &state_path,
9569 serde_json::to_vec_pretty(&state).expect("relay_state serializable"),
9570 ) {
9571 eprintln!("wire session new: failed to write {state_path:?}: {e}");
9572 return;
9573 }
9574 eprintln!(
9575 "wire session new: UDS slot allocated on unix://{} (slot_id={}) — sister sessions will see this endpoint in your agent-card",
9576 uds_socket.display(),
9577 alloc.slot_id
9578 );
9579}
9580
9581#[cfg(not(unix))]
9582fn try_allocate_uds_slot(
9583 _session_home: &std::path::Path,
9584 _handle: &str,
9585 _uds_socket: &std::path::Path,
9586) {
9587 eprintln!(
9588 "wire session new: --with-uds is Unix-only (Windows lacks AF_UNIX in tokio/reqwest); ignoring"
9589 );
9590}
9591
9592fn try_allocate_lan_slot(session_home: &std::path::Path, handle: &str, lan_relay: &str) {
9602 let probe = match crate::relay_client::build_blocking_client(Some(
9603 std::time::Duration::from_millis(500),
9604 )) {
9605 Ok(c) => c,
9606 Err(e) => {
9607 eprintln!("wire session new: cannot build LAN probe client for {lan_relay}: {e:#}");
9608 return;
9609 }
9610 };
9611 let healthz_url = format!("{}/healthz", lan_relay.trim_end_matches('/'));
9612 match probe.get(&healthz_url).send() {
9613 Ok(resp) if resp.status().is_success() => {}
9614 Ok(resp) => {
9615 eprintln!(
9616 "wire session new: LAN relay probe at {healthz_url} returned {} — not publishing LAN endpoint",
9617 resp.status()
9618 );
9619 return;
9620 }
9621 Err(e) => {
9622 eprintln!(
9623 "wire session new: LAN relay at {lan_relay} unreachable ({}) — not publishing LAN endpoint. \
9624 Start one on the LAN-bound interface with `wire relay-server --bind <LAN-IP>:8771 --local-only`.",
9625 crate::relay_client::format_transport_error(&anyhow::Error::new(e))
9626 );
9627 return;
9628 }
9629 };
9630
9631 let lan_client = crate::relay_client::RelayClient::new(lan_relay);
9632 let alloc = match lan_client.allocate_slot(Some(handle)) {
9633 Ok(a) => a,
9634 Err(e) => {
9635 eprintln!(
9636 "wire session new: LAN relay slot allocation failed: {e:#} — not publishing LAN endpoint"
9637 );
9638 return;
9639 }
9640 };
9641
9642 let state_path = session_home.join("config").join("wire").join("relay.json");
9643 let mut state: serde_json::Value = std::fs::read(&state_path)
9644 .ok()
9645 .and_then(|b| serde_json::from_slice(&b).ok())
9646 .unwrap_or_else(|| serde_json::json!({}));
9647
9648 let mut endpoints: Vec<crate::endpoints::Endpoint> = state
9651 .get("self")
9652 .and_then(|s| s.get("endpoints"))
9653 .and_then(|e| e.as_array())
9654 .map(|arr| {
9655 arr.iter()
9656 .filter_map(|v| {
9657 serde_json::from_value::<crate::endpoints::Endpoint>(v.clone()).ok()
9658 })
9659 .collect()
9660 })
9661 .unwrap_or_default();
9662 endpoints.push(crate::endpoints::Endpoint::lan(
9663 lan_relay.trim_end_matches('/').to_string(),
9664 alloc.slot_id.clone(),
9665 alloc.slot_token.clone(),
9666 ));
9667
9668 let self_obj = state
9669 .as_object_mut()
9670 .expect("relay_state root is an object")
9671 .entry("self")
9672 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
9673 if !self_obj.is_object() {
9674 *self_obj = serde_json::Value::Object(serde_json::Map::new());
9675 }
9676 if let Some(obj) = self_obj.as_object_mut() {
9677 obj.insert(
9678 "endpoints".into(),
9679 serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
9680 );
9681 }
9682 if let Err(e) = std::fs::write(
9683 &state_path,
9684 serde_json::to_vec_pretty(&state).expect("relay_state serializable"),
9685 ) {
9686 eprintln!("wire session new: failed to write {state_path:?}: {e}");
9687 return;
9688 }
9689 eprintln!(
9690 "wire session new: LAN slot allocated on {lan_relay} (slot_id={}) — peers will see this endpoint in your agent-card",
9691 alloc.slot_id
9692 );
9693}
9694
9695fn try_allocate_local_slot(
9703 session_home: &std::path::Path,
9704 handle: &str,
9705 _federation_relay: &str,
9706 local_relay: &str,
9707) {
9708 let probe = match crate::relay_client::build_blocking_client(Some(
9711 std::time::Duration::from_millis(500),
9712 )) {
9713 Ok(c) => c,
9714 Err(e) => {
9715 eprintln!("wire session new: cannot build probe client for {local_relay}: {e:#}");
9716 return;
9717 }
9718 };
9719 let healthz_url = format!("{}/healthz", local_relay.trim_end_matches('/'));
9720 match probe.get(&healthz_url).send() {
9721 Ok(resp) if resp.status().is_success() => {}
9722 Ok(resp) => {
9723 eprintln!(
9724 "wire session new: local relay probe at {healthz_url} returned {} — staying federation-only",
9725 resp.status()
9726 );
9727 return;
9728 }
9729 Err(e) => {
9730 eprintln!(
9731 "wire session new: local relay at {local_relay} unreachable ({}) — staying federation-only. \
9732 Start one with `wire relay-server --bind 127.0.0.1:8771 --local-only`.",
9733 crate::relay_client::format_transport_error(&anyhow::Error::new(e))
9734 );
9735 return;
9736 }
9737 };
9738
9739 let local_client = crate::relay_client::RelayClient::new(local_relay);
9741 let alloc = match local_client.allocate_slot(Some(handle)) {
9742 Ok(a) => a,
9743 Err(e) => {
9744 eprintln!(
9745 "wire session new: local relay slot allocation failed: {e:#} — staying federation-only"
9746 );
9747 return;
9748 }
9749 };
9750
9751 let state_path = session_home.join("config").join("wire").join("relay.json");
9766 let mut state: serde_json::Value = std::fs::read(&state_path)
9767 .ok()
9768 .and_then(|b| serde_json::from_slice(&b).ok())
9769 .unwrap_or_else(|| serde_json::json!({}));
9770 let fed_endpoint = state.get("self").and_then(|s| {
9773 let url = s.get("relay_url").and_then(serde_json::Value::as_str)?;
9774 let slot_id = s.get("slot_id").and_then(serde_json::Value::as_str)?;
9775 let slot_token = s.get("slot_token").and_then(serde_json::Value::as_str)?;
9776 Some(crate::endpoints::Endpoint::federation(
9777 url.to_string(),
9778 slot_id.to_string(),
9779 slot_token.to_string(),
9780 ))
9781 });
9782
9783 let local_endpoint = crate::endpoints::Endpoint::local(
9784 local_relay.trim_end_matches('/').to_string(),
9785 alloc.slot_id.clone(),
9786 alloc.slot_token.clone(),
9787 );
9788
9789 let mut endpoints: Vec<crate::endpoints::Endpoint> = Vec::new();
9790 if let Some(f) = fed_endpoint.clone() {
9791 endpoints.push(f);
9792 }
9793 endpoints.push(local_endpoint);
9794
9795 let (legacy_relay, legacy_slot_id, legacy_slot_token) = match fed_endpoint.clone() {
9805 Some(f) => (f.relay_url, f.slot_id, f.slot_token),
9806 None => (
9807 local_relay.trim_end_matches('/').to_string(),
9808 alloc.slot_id.clone(),
9809 alloc.slot_token.clone(),
9810 ),
9811 };
9812 let self_obj = state
9813 .as_object_mut()
9814 .expect("relay_state root is an object")
9815 .entry("self")
9816 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
9817 if !self_obj.is_object() {
9820 *self_obj = serde_json::Value::Object(serde_json::Map::new());
9821 }
9822 if let Some(obj) = self_obj.as_object_mut() {
9823 obj.insert("relay_url".into(), serde_json::Value::String(legacy_relay));
9824 obj.insert("slot_id".into(), serde_json::Value::String(legacy_slot_id));
9825 obj.insert(
9826 "slot_token".into(),
9827 serde_json::Value::String(legacy_slot_token),
9828 );
9829 obj.insert(
9830 "endpoints".into(),
9831 serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
9832 );
9833 }
9834
9835 if let Err(e) = std::fs::write(
9836 &state_path,
9837 serde_json::to_vec_pretty(&state).unwrap_or_default(),
9838 ) {
9839 eprintln!(
9840 "wire session new: persisting dual-slot relay_state at {state_path:?} failed: {e}"
9841 );
9842 return;
9843 }
9844 eprintln!(
9845 "wire session new: local slot allocated on {local_relay} (slot_id={})",
9846 alloc.slot_id
9847 );
9848}
9849
9850fn render_session_info(
9851 name: &str,
9852 session_home: &std::path::Path,
9853 cwd: &std::path::Path,
9854) -> Result<serde_json::Value> {
9855 let card_path = session_home
9856 .join("config")
9857 .join("wire")
9858 .join("agent-card.json");
9859 let (did, handle) = if card_path.exists() {
9860 let card: Value = serde_json::from_slice(&std::fs::read(&card_path)?)?;
9861 let did = card
9862 .get("did")
9863 .and_then(Value::as_str)
9864 .unwrap_or("")
9865 .to_string();
9866 let handle = card
9867 .get("handle")
9868 .and_then(Value::as_str)
9869 .map(str::to_string)
9870 .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
9871 (did, handle)
9872 } else {
9873 (String::new(), String::new())
9874 };
9875 Ok(json!({
9876 "name": name,
9877 "home_dir": session_home.to_string_lossy(),
9878 "cwd": cwd.to_string_lossy(),
9879 "did": did,
9880 "handle": handle,
9881 "export": format!("export WIRE_HOME={}", session_home.to_string_lossy()),
9882 }))
9883}
9884
9885fn emit_session_new_result(info: &serde_json::Value, status: &str, as_json: bool) -> Result<()> {
9886 if as_json {
9887 let mut obj = info.clone();
9888 obj["status"] = json!(status);
9889 println!("{}", serde_json::to_string(&obj)?);
9890 } else {
9891 let name = info["name"].as_str().unwrap_or("?");
9892 let handle = info["handle"].as_str().unwrap_or("?");
9893 let home = info["home_dir"].as_str().unwrap_or("?");
9894 let did = info["did"].as_str().unwrap_or("?");
9895 let export = info["export"].as_str().unwrap_or("?");
9896 let prefix = if status == "already_exists" {
9897 "session already exists (re-registered cwd)"
9898 } else {
9899 "session created"
9900 };
9901 println!(
9902 "{prefix}\n name: {name}\n handle: {handle}\n did: {did}\n home: {home}\n\nactivate with:\n {export}"
9903 );
9904 }
9905 Ok(())
9906}
9907
9908fn run_wire_with_home(
9909 session_home: &std::path::Path,
9910 args: &[&str],
9911) -> Result<std::process::ExitStatus> {
9912 let bin = std::env::current_exe().with_context(|| "locating self exe")?;
9913 let status = std::process::Command::new(&bin)
9914 .env("WIRE_HOME", session_home)
9915 .env_remove("RUST_LOG")
9916 .env("WIRE_AUTO_INIT", "0")
9919 .args(args)
9920 .status()
9921 .with_context(|| format!("spawning `wire {}`", args.join(" ")))?;
9922 Ok(status)
9923}
9924
9925pub fn maybe_auto_init_cwd_session(label: &str) {
9944 if std::env::var("WIRE_HOME").is_ok() {
9945 return; }
9947 if std::env::var("WIRE_AUTO_INIT").as_deref() == Ok("0") {
9948 return; }
9950 let cwd = match std::env::current_dir() {
9951 Ok(c) => c,
9952 Err(_) => return,
9953 };
9954 if crate::session::detect_session_wire_home(&cwd).is_some() {
9957 return;
9958 }
9959
9960 use fs2::FileExt;
9977 let sessions_root = match crate::session::sessions_root() {
9978 Ok(r) => r,
9979 Err(_) => return,
9980 };
9981 if let Err(e) = std::fs::create_dir_all(&sessions_root) {
9982 eprintln!("wire {label}: auto-init: failed to create sessions root {sessions_root:?}: {e}");
9983 return;
9984 }
9985 let lock_path = sessions_root.join(".auto-init.lock");
9986 let lock_file = match std::fs::OpenOptions::new()
9987 .create(true)
9988 .truncate(false)
9989 .read(true)
9990 .write(true)
9991 .open(&lock_path)
9992 {
9993 Ok(f) => f,
9994 Err(e) => {
9995 eprintln!(
9996 "wire {label}: auto-init: cannot open lockfile {lock_path:?}: {e} — falling back to default identity"
9997 );
9998 return;
9999 }
10000 };
10001 if let Err(e) = lock_file.lock_exclusive() {
10002 eprintln!(
10003 "wire {label}: auto-init: flock {lock_path:?} failed: {e} — falling back to default identity"
10004 );
10005 return;
10006 }
10007 let registry = crate::session::read_registry().unwrap_or_default();
10012 let name = crate::session::derive_name_from_cwd(&cwd, ®istry);
10013 let session_home = match crate::session::session_dir(&name) {
10014 Ok(h) => h,
10015 Err(_) => {
10016 let _ = fs2::FileExt::unlock(&lock_file);
10017 return;
10018 }
10019 };
10020 let agent_card_path = session_home
10021 .join("config")
10022 .join("wire")
10023 .join("agent-card.json");
10024 let needs_init = !agent_card_path.exists();
10025
10026 if needs_init {
10027 if let Err(e) = std::fs::create_dir_all(&session_home) {
10028 eprintln!(
10029 "wire {label}: auto-init: failed to create session dir {session_home:?}: {e}"
10030 );
10031 let _ = fs2::FileExt::unlock(&lock_file);
10032 return;
10033 }
10034 match run_wire_with_home(&session_home, &["init", &name, "--offline"]) {
10039 Ok(status) if status.success() => {}
10040 Ok(status) => {
10041 eprintln!(
10042 "wire {label}: auto-init: `wire init {name}` exited non-zero ({status}) — falling back to default identity"
10043 );
10044 let _ = fs2::FileExt::unlock(&lock_file);
10045 return;
10046 }
10047 Err(e) => {
10048 eprintln!(
10049 "wire {label}: auto-init: failed to spawn `wire init {name}`: {e:#} — falling back to default identity"
10050 );
10051 let _ = fs2::FileExt::unlock(&lock_file);
10052 return;
10053 }
10054 }
10055 try_allocate_local_slot(
10062 &session_home,
10063 &name,
10064 "https://wireup.net",
10065 "http://127.0.0.1:8771",
10066 );
10067 } else {
10068 if std::env::var("WIRE_QUIET_AUTOSESSION").is_err() {
10072 eprintln!(
10073 "wire {label}: auto-init: session `{name}` already exists (concurrent mcp peer won the race) — adopting"
10074 );
10075 }
10076 }
10077 let cwd_key = crate::session::normalize_cwd_key(&cwd);
10087 let name_for_reg = name.clone();
10088 if let Err(e) = crate::session::update_registry(|reg| {
10089 reg.by_cwd.insert(cwd_key, name_for_reg);
10090 Ok(())
10091 }) {
10092 eprintln!("wire {label}: auto-init: failed to update registry: {e:#}");
10093 }
10095 let _ = fs2::FileExt::unlock(&lock_file);
10098
10099 if std::env::var("WIRE_QUIET_AUTOSESSION").is_err() {
10100 eprintln!(
10101 "wire {label}: auto-init: created session `{name}` for cwd `{}` → WIRE_HOME=`{}`",
10102 cwd.display(),
10103 session_home.display()
10104 );
10105 }
10106 unsafe {
10109 std::env::set_var("WIRE_HOME", &session_home);
10110 }
10111}
10112
10113fn ensure_session_daemon(session_home: &std::path::Path) -> Result<()> {
10114 let pidfile = session_home.join("state").join("wire").join("daemon.pid");
10117 if pidfile.exists() {
10118 let bytes = std::fs::read(&pidfile).unwrap_or_default();
10119 let pid: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
10120 v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
10121 } else {
10122 String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
10123 };
10124 if let Some(p) = pid {
10125 let alive = {
10126 #[cfg(target_os = "linux")]
10127 {
10128 std::path::Path::new(&format!("/proc/{p}")).exists()
10129 }
10130 #[cfg(not(target_os = "linux"))]
10131 {
10132 std::process::Command::new("kill")
10133 .args(["-0", &p.to_string()])
10134 .output()
10135 .map(|o| o.status.success())
10136 .unwrap_or(false)
10137 }
10138 };
10139 if alive {
10140 return Ok(());
10141 }
10142 }
10143 }
10144
10145 let bin = std::env::current_exe().with_context(|| "locating self exe")?;
10148 let log_path = session_home.join("state").join("wire").join("daemon.log");
10149 if let Some(parent) = log_path.parent() {
10150 std::fs::create_dir_all(parent).ok();
10151 }
10152 let log_file = std::fs::OpenOptions::new()
10153 .create(true)
10154 .append(true)
10155 .open(&log_path)
10156 .with_context(|| format!("opening daemon log {log_path:?}"))?;
10157 let log_err = log_file.try_clone()?;
10158 std::process::Command::new(&bin)
10159 .env("WIRE_HOME", session_home)
10160 .env_remove("RUST_LOG")
10161 .args(["daemon", "--interval", "5"])
10162 .stdout(log_file)
10163 .stderr(log_err)
10164 .stdin(std::process::Stdio::null())
10165 .spawn()
10166 .with_context(|| "spawning session-local `wire daemon`")?;
10167 Ok(())
10168}
10169
10170fn cmd_session_list(as_json: bool) -> Result<()> {
10171 let items = crate::session::list_sessions()?;
10172 if as_json {
10173 println!("{}", serde_json::to_string(&items)?);
10174 return Ok(());
10175 }
10176 if items.is_empty() {
10177 println!("no sessions on this machine. `wire session new` to create one.");
10178 return Ok(());
10179 }
10180 println!(
10181 "{:<22} {:<24} {:<24} {:<10} CWD",
10182 "PERSONA", "NAME", "HANDLE", "DAEMON"
10183 );
10184 for s in items {
10185 let plain = s
10189 .character
10190 .as_ref()
10191 .map(|c| c.short())
10192 .unwrap_or_else(|| "?".to_string());
10193 let colored = s
10194 .character
10195 .as_ref()
10196 .map(|c| c.colored())
10197 .unwrap_or_else(|| "?".to_string());
10198 let displayed_width = plain.chars().count() + 1; let pad = 22usize.saturating_sub(displayed_width);
10203 println!(
10204 "{}{} {:<24} {:<24} {:<10} {}",
10205 colored,
10206 " ".repeat(pad),
10207 s.name,
10208 s.handle.as_deref().unwrap_or("?"),
10209 if s.daemon_running { "running" } else { "down" },
10210 s.cwd.as_deref().unwrap_or("(no cwd registered)"),
10211 );
10212 }
10213 Ok(())
10214}
10215
10216fn cmd_session_list_local(as_json: bool) -> Result<()> {
10228 let listing = crate::session::list_local_sessions()?;
10229 if as_json {
10230 println!("{}", serde_json::to_string(&listing)?);
10231 return Ok(());
10232 }
10233
10234 if listing.local.is_empty() && listing.federation_only.is_empty() {
10235 println!(
10236 "no sessions on this machine. `wire session new --with-local` to create one \
10237 with a local-relay endpoint (start the relay first: \
10238 `wire relay-server --bind 127.0.0.1:8771 --local-only`)."
10239 );
10240 return Ok(());
10241 }
10242
10243 if listing.local.is_empty() {
10244 println!(
10245 "no sister sessions reachable via a local relay. \
10246 Re-run `wire session new --with-local` to add a Local endpoint, or \
10247 start a local relay with `wire relay-server --bind 127.0.0.1:8771 --local-only`."
10248 );
10249 } else {
10250 let mut keys: Vec<&String> = listing.local.keys().collect();
10252 keys.sort();
10253 for relay_url in keys {
10254 let group = &listing.local[relay_url];
10255 println!("LOCAL RELAY: {relay_url}");
10256 println!(" {:<24} {:<32} {:<10} CWD", "NAME", "HANDLE", "DAEMON");
10257 for s in group {
10258 println!(
10259 " {:<24} {:<32} {:<10} {}",
10260 s.name,
10261 s.handle.as_deref().unwrap_or("?"),
10262 if s.daemon_running { "running" } else { "down" },
10263 s.cwd.as_deref().unwrap_or("(no cwd registered)"),
10264 );
10265 }
10266 println!();
10267 }
10268 }
10269
10270 if !listing.federation_only.is_empty() {
10271 println!("federation-only (no local endpoint):");
10272 for s in &listing.federation_only {
10273 println!(
10274 " {:<24} {:<32} {}",
10275 s.name,
10276 s.handle.as_deref().unwrap_or("?"),
10277 s.cwd.as_deref().unwrap_or("(no cwd registered)"),
10278 );
10279 }
10280 }
10281 Ok(())
10282}
10283
10284fn cmd_session_pair_all_local(
10303 settle_secs: u64,
10304 federation_relay: &str,
10305 as_json: bool,
10306) -> Result<()> {
10307 use std::collections::BTreeSet;
10308 use std::time::Duration;
10309
10310 let listing = crate::session::list_local_sessions()?;
10311 let mut by_name: std::collections::BTreeMap<String, crate::session::LocalSessionView> =
10315 Default::default();
10316 for group in listing.local.into_values() {
10317 for s in group {
10318 by_name.entry(s.name.clone()).or_insert(s);
10319 }
10320 }
10321 let sessions: Vec<crate::session::LocalSessionView> = by_name.into_values().collect();
10322
10323 if sessions.len() < 2 {
10324 let msg = format!(
10325 "{} sister session(s) with a local endpoint — need at least 2 to pair.",
10326 sessions.len()
10327 );
10328 if as_json {
10329 println!(
10330 "{}",
10331 serde_json::to_string(&json!({
10332 "sessions": sessions.iter().map(|s| &s.name).collect::<Vec<_>>(),
10333 "pairs_attempted": 0,
10334 "pairs_succeeded": 0,
10335 "pairs_skipped_already_paired": 0,
10336 "pairs_failed": 0,
10337 "note": msg,
10338 }))?
10339 );
10340 } else {
10341 println!("{msg}");
10342 if let Some(s) = sessions.first() {
10343 println!(" - {} ({})", s.name, s.cwd.as_deref().unwrap_or("?"));
10344 }
10345 println!("Use `wire session new --with-local` to add more.");
10346 }
10347 return Ok(());
10348 }
10349
10350 let fed_host = host_of_url(federation_relay);
10351 if fed_host.is_empty() {
10352 bail!(
10353 "federation_relay `{federation_relay}` has no parseable host — \
10354 pass a full URL like `https://wireup.net`."
10355 );
10356 }
10357
10358 let mut attempted = 0u32;
10360 let mut succeeded = 0u32;
10361 let mut skipped_already = 0u32;
10362 let mut failed = 0u32;
10363 let mut per_pair: Vec<Value> = Vec::new();
10364
10365 for i in 0..sessions.len() {
10366 for j in (i + 1)..sessions.len() {
10367 let a = &sessions[i];
10368 let b = &sessions[j];
10369 attempted += 1;
10370
10371 let a_handle = a.handle.as_deref().unwrap_or(a.name.as_str());
10377 let b_handle = b.handle.as_deref().unwrap_or(b.name.as_str());
10378 let a_pinned_b = session_has_peer(&a.home_dir, b_handle);
10379 let b_pinned_a = session_has_peer(&b.home_dir, a_handle);
10380 if a_pinned_b && b_pinned_a {
10381 skipped_already += 1;
10382 per_pair.push(json!({
10383 "from": a.name,
10384 "to": b.name,
10385 "status": "already_paired",
10386 }));
10387 continue;
10388 }
10389
10390 let pair_result = drive_bilateral_pair(
10391 &a.home_dir,
10392 &a.name,
10393 &b.home_dir,
10394 &b.name,
10395 &fed_host,
10396 federation_relay,
10397 settle_secs,
10398 );
10399
10400 match pair_result {
10401 Ok(()) => {
10402 succeeded += 1;
10403 per_pair.push(json!({
10404 "from": a.name,
10405 "to": b.name,
10406 "status": "paired",
10407 }));
10408 }
10409 Err(e) => {
10410 failed += 1;
10411 let detail = format!("{e:#}");
10412 per_pair.push(json!({
10413 "from": a.name,
10414 "to": b.name,
10415 "status": "failed",
10416 "error": detail,
10417 }));
10418 }
10419 }
10420
10421 std::thread::sleep(Duration::from_millis(200));
10424 }
10425 }
10426
10427 let _ = BTreeSet::<String>::new(); let summary = json!({
10429 "sessions": sessions.iter().map(|s| s.name.clone()).collect::<Vec<_>>(),
10430 "pairs_attempted": attempted,
10431 "pairs_succeeded": succeeded,
10432 "pairs_skipped_already_paired": skipped_already,
10433 "pairs_failed": failed,
10434 "results": per_pair,
10435 });
10436 if as_json {
10437 println!("{}", serde_json::to_string(&summary)?);
10438 } else {
10439 println!(
10440 "wire session pair-all-local: {} session(s), {} pair(s) attempted",
10441 sessions.len(),
10442 attempted
10443 );
10444 println!(" paired: {succeeded}");
10445 println!(" skipped (already pinned): {skipped_already}");
10446 println!(" failed: {failed}");
10447 for entry in summary["results"].as_array().unwrap_or(&vec![]) {
10448 let from = entry["from"].as_str().unwrap_or("?");
10449 let to = entry["to"].as_str().unwrap_or("?");
10450 let status = entry["status"].as_str().unwrap_or("?");
10451 let err = entry.get("error").and_then(Value::as_str).unwrap_or("");
10452 if err.is_empty() {
10453 println!(" {from:<24} ↔ {to:<24} {status}");
10454 } else {
10455 println!(" {from:<24} ↔ {to:<24} {status} — {err}");
10456 }
10457 }
10458 }
10459 Ok(())
10460}
10461
10462fn session_has_peer(session_home: &std::path::Path, peer_name: &str) -> bool {
10465 val_session_relay_state(session_home)
10466 .and_then(|v| v.get("peers").cloned())
10467 .and_then(|p| p.get(peer_name).cloned())
10468 .is_some()
10469}
10470
10471fn val_session_relay_state(session_home: &std::path::Path) -> Option<Value> {
10476 let path = session_home.join("config").join("wire").join("relay.json");
10477 let bytes = std::fs::read(&path).ok()?;
10478 serde_json::from_slice(&bytes).ok()
10479}
10480
10481fn cmd_session_mesh_status(stale_secs: u64, as_json: bool) -> Result<()> {
10485 use std::collections::BTreeMap;
10486
10487 let listing = crate::session::list_local_sessions()?;
10490 let mut by_name: BTreeMap<String, crate::session::LocalSessionView> = BTreeMap::new();
10491 for group in listing.local.into_values() {
10492 for s in group {
10493 by_name.entry(s.name.clone()).or_insert(s);
10494 }
10495 }
10496 let sessions: Vec<crate::session::LocalSessionView> = by_name.into_values().collect();
10497 let federation_only = listing.federation_only;
10498
10499 if sessions.is_empty() {
10500 let msg = "no sister sessions with a local endpoint on this machine.".to_string();
10501 if as_json {
10502 println!(
10503 "{}",
10504 serde_json::to_string(&json!({
10505 "sessions": [],
10506 "edges": [],
10507 "local_relay": null,
10508 "federation_only": federation_only.iter().map(|f| &f.name).collect::<Vec<_>>(),
10509 "summary": {
10510 "session_count": 0,
10511 "edge_count": 0,
10512 "healthy": 0,
10513 "stale": 0,
10514 "asymmetric": 0,
10515 },
10516 "note": msg,
10517 }))?
10518 );
10519 } else {
10520 println!("{msg}");
10521 println!("Use `wire session new --with-local` to create one.");
10522 }
10523 return Ok(());
10524 }
10525
10526 struct SessionState {
10528 view: crate::session::LocalSessionView,
10529 relay_state: Value,
10530 local_relay_url: Option<String>,
10531 }
10532 let mut sstates: Vec<SessionState> = Vec::with_capacity(sessions.len());
10533 for s in sessions {
10534 let relay_state = val_session_relay_state(&s.home_dir)
10535 .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
10536 let local_relay_url = s.local_endpoints.first().map(|e| e.relay_url.clone());
10537 sstates.push(SessionState {
10538 view: s,
10539 relay_state,
10540 local_relay_url,
10541 });
10542 }
10543
10544 let mut local_relays: BTreeMap<String, bool> = BTreeMap::new();
10547 for s in &sstates {
10548 if let Some(url) = &s.local_relay_url
10549 && !local_relays.contains_key(url)
10550 {
10551 let healthy = probe_relay_healthz(url);
10552 local_relays.insert(url.clone(), healthy);
10553 }
10554 }
10555
10556 let now = std::time::SystemTime::now()
10557 .duration_since(std::time::UNIX_EPOCH)
10558 .map(|d| d.as_secs())
10559 .unwrap_or(0);
10560
10561 let mut edges: Vec<Value> = Vec::new();
10565 let mut healthy_count = 0u32;
10566 let mut stale_count = 0u32;
10567 let mut asymmetric_count = 0u32;
10568
10569 for i in 0..sstates.len() {
10570 for j in (i + 1)..sstates.len() {
10571 let a = &sstates[i];
10572 let b = &sstates[j];
10573 let b_key = b.view.handle.as_deref().unwrap_or(b.view.name.as_str());
10578 let a_key = a.view.handle.as_deref().unwrap_or(a.view.name.as_str());
10579 let a_to_b = probe_directed_edge(&a.relay_state, b_key, now);
10580 let b_to_a = probe_directed_edge(&b.relay_state, a_key, now);
10581
10582 let bilateral = a_to_b.pinned && b_to_a.pinned;
10583 let scope = match (a_to_b.scope.as_deref(), b_to_a.scope.as_deref()) {
10587 (Some("local"), _) | (_, Some("local")) => "local",
10588 (Some("federation"), _) | (_, Some("federation")) => "federation",
10589 _ => "unknown",
10590 };
10591
10592 let mut status = if bilateral { "healthy" } else { "asymmetric" };
10595 if bilateral {
10596 let either_stale = [&a_to_b, &b_to_a].iter().any(|d| match d.silent_secs {
10597 Some(s) => s > stale_secs,
10598 None => d.probed,
10599 });
10600 if either_stale {
10601 status = "stale";
10602 }
10603 }
10604
10605 match status {
10606 "healthy" => healthy_count += 1,
10607 "stale" => stale_count += 1,
10608 "asymmetric" => asymmetric_count += 1,
10609 _ => {}
10610 }
10611
10612 edges.push(json!({
10613 "from": a.view.name,
10614 "to": b.view.name,
10615 "bilateral": bilateral,
10616 "scope": scope,
10617 "status": status,
10618 "directions": {
10619 a.view.name.clone(): direction_summary(&a_to_b),
10620 b.view.name.clone(): direction_summary(&b_to_a),
10621 },
10622 }));
10623 }
10624 }
10625
10626 let summary = json!({
10627 "sessions": sstates.iter().map(|s| json!({
10628 "name": s.view.name,
10629 "handle": s.view.handle,
10630 "cwd": s.view.cwd,
10631 "daemon_running": s.view.daemon_running,
10632 "local_relay": s.local_relay_url,
10633 })).collect::<Vec<_>>(),
10634 "edges": edges,
10635 "local_relays": local_relays.iter().map(|(url, healthy)| json!({
10636 "url": url,
10637 "healthy": healthy,
10638 })).collect::<Vec<_>>(),
10639 "federation_only": federation_only.iter().map(|f| &f.name).collect::<Vec<_>>(),
10640 "summary": {
10641 "session_count": sstates.len(),
10642 "edge_count": edges.len(),
10643 "healthy": healthy_count,
10644 "stale": stale_count,
10645 "asymmetric": asymmetric_count,
10646 "stale_threshold_secs": stale_secs,
10647 },
10648 });
10649
10650 if as_json {
10651 println!("{}", serde_json::to_string(&summary)?);
10652 return Ok(());
10653 }
10654
10655 println!(
10656 "wire mesh: {} session(s), {} edge(s)",
10657 sstates.len(),
10658 edges.len()
10659 );
10660 for (url, healthy) in &local_relays {
10661 let tick = if *healthy { "✓" } else { "✗" };
10662 println!(" local-relay {url} {tick}");
10663 }
10664 if !federation_only.is_empty() {
10665 print!(" federation-only sessions:");
10666 for f in &federation_only {
10667 print!(" {}", f.name);
10668 }
10669 println!();
10670 }
10671
10672 let names: Vec<&str> = sstates.iter().map(|s| s.view.name.as_str()).collect();
10674 let col_w = names.iter().map(|n| n.len()).max().unwrap_or(8).max(7) + 1;
10675 print!("\n{:>col_w$}", "", col_w = col_w);
10676 for n in &names {
10677 print!("{n:>col_w$}");
10678 }
10679 println!();
10680 for (i, row) in names.iter().enumerate() {
10681 print!("{row:>col_w$}");
10682 for (j, col) in names.iter().enumerate() {
10683 let cell = if i == j {
10684 "self".to_string()
10685 } else {
10686 let d = probe_directed_edge(&sstates[i].relay_state, col, now);
10687 match d.scope.as_deref() {
10688 Some("local") => "local".to_string(),
10689 Some("federation") => "fed".to_string(),
10690 _ => "—".to_string(),
10691 }
10692 };
10693 print!("{cell:>col_w$}");
10694 }
10695 println!();
10696 }
10697
10698 println!("\nHealth (stale threshold: {stale_secs}s):");
10699 for e in &edges {
10700 let from = e["from"].as_str().unwrap_or("?");
10701 let to = e["to"].as_str().unwrap_or("?");
10702 let scope = e["scope"].as_str().unwrap_or("?");
10703 let status = e["status"].as_str().unwrap_or("?");
10704 let mark = match status {
10705 "healthy" => "✓",
10706 "stale" => "⚠",
10707 "asymmetric" => "!",
10708 _ => "?",
10709 };
10710 let dirs = e["directions"].as_object().cloned().unwrap_or_default();
10711 let mut details: Vec<String> = Vec::new();
10712 for (who, d) in &dirs {
10713 let silent = d.get("silent_secs").and_then(Value::as_u64);
10714 let pinned = d.get("pinned").and_then(Value::as_bool).unwrap_or(false);
10715 let probed = d.get("probed").and_then(Value::as_bool).unwrap_or(false);
10716 let label = match (pinned, probed, silent) {
10717 (false, _, _) => format!("{who} has not pinned"),
10718 (true, false, _) => format!("{who} pinned but no endpoint to probe"),
10719 (true, true, Some(s)) if s <= stale_secs => format!("{who} fresh ({s}s)"),
10720 (true, true, Some(s)) => format!("{who} silent {s}s"),
10721 (true, true, None) => format!("{who} never pulled"),
10722 };
10723 details.push(label);
10724 }
10725 println!(
10726 " {mark} {from} ↔ {to} scope={scope} {status:>10} [{}]",
10727 details.join(" | ")
10728 );
10729 }
10730 Ok(())
10731}
10732
10733#[derive(Default)]
10734struct DirectedEdge {
10735 pinned: bool,
10736 scope: Option<String>,
10737 last_pull_at_unix: Option<u64>,
10738 silent_secs: Option<u64>,
10739 probed: bool,
10740 event_count: usize,
10741}
10742
10743fn probe_directed_edge(from_state: &Value, to_name: &str, now: u64) -> DirectedEdge {
10749 let pinned = from_state
10750 .get("peers")
10751 .and_then(|p| p.get(to_name))
10752 .is_some();
10753 if !pinned {
10754 return DirectedEdge::default();
10755 }
10756 let endpoints = crate::endpoints::peer_endpoints_in_priority_order(from_state, to_name);
10757 let ep = match endpoints.into_iter().next() {
10758 Some(e) => e,
10759 None => {
10760 return DirectedEdge {
10761 pinned: true,
10762 ..Default::default()
10763 };
10764 }
10765 };
10766 let scope = Some(
10767 match ep.scope {
10768 crate::endpoints::EndpointScope::Local => "local",
10769 crate::endpoints::EndpointScope::Lan => "lan",
10770 crate::endpoints::EndpointScope::Uds => "uds",
10771 crate::endpoints::EndpointScope::Federation => "federation",
10772 }
10773 .to_string(),
10774 );
10775 let client = crate::relay_client::RelayClient::new(&ep.relay_url);
10776 let (count, last) = client
10777 .slot_state(&ep.slot_id, &ep.slot_token)
10778 .unwrap_or((0, None));
10779 let silent = last.map(|t| now.saturating_sub(t));
10780 DirectedEdge {
10781 pinned: true,
10782 scope,
10783 last_pull_at_unix: last,
10784 silent_secs: silent,
10785 probed: true,
10786 event_count: count,
10787 }
10788}
10789
10790fn direction_summary(d: &DirectedEdge) -> Value {
10791 json!({
10792 "pinned": d.pinned,
10793 "scope": d.scope,
10794 "probed": d.probed,
10795 "last_pull_at_unix": d.last_pull_at_unix,
10796 "silent_secs": d.silent_secs,
10797 "event_count": d.event_count,
10798 })
10799}
10800
10801fn probe_relay_healthz(url: &str) -> bool {
10803 let probe_url = format!("{}/healthz", url.trim_end_matches('/'));
10804 let client = match reqwest::blocking::Client::builder()
10805 .timeout(std::time::Duration::from_millis(500))
10806 .build()
10807 {
10808 Ok(c) => c,
10809 Err(_) => return false,
10810 };
10811 match client.get(&probe_url).send() {
10812 Ok(r) => r.status().is_success(),
10813 Err(_) => false,
10814 }
10815}
10816
10817fn drive_bilateral_pair(
10832 a_home: &std::path::Path,
10833 a_name: &str,
10834 b_home: &std::path::Path,
10835 b_name: &str,
10836 _fed_host: &str,
10837 _federation_relay: &str,
10838 settle_secs: u64,
10839) -> Result<()> {
10840 use std::time::Duration;
10841 let bin = std::env::current_exe().context("locating self exe")?;
10842
10843 let run = |home: &std::path::Path, args: &[&str]| -> Result<()> {
10844 let out = std::process::Command::new(&bin)
10845 .env("WIRE_HOME", home)
10846 .env_remove("RUST_LOG")
10847 .args(args)
10848 .output()
10849 .with_context(|| format!("spawning `wire {}`", args.join(" ")))?;
10850 if !out.status.success() {
10851 bail!(
10852 "`wire {}` failed: stderr={}",
10853 args.join(" "),
10854 String::from_utf8_lossy(&out.stderr).trim()
10855 );
10856 }
10857 Ok(())
10858 };
10859
10860 let read_card_handle = |home: &std::path::Path| -> Result<String> {
10865 let card_path = home.join("config").join("wire").join("agent-card.json");
10866 let bytes = std::fs::read(&card_path)
10867 .with_context(|| format!("reading agent-card at {card_path:?}"))?;
10868 let card: Value = serde_json::from_slice(&bytes)?;
10869 card.get("handle")
10870 .and_then(Value::as_str)
10871 .map(str::to_string)
10872 .ok_or_else(|| anyhow!("agent-card at {card_path:?} missing `handle` field"))
10873 };
10874 let a_handle = read_card_handle(a_home)
10875 .with_context(|| format!("session {a_name} (a): read agent-card.handle"))?;
10876 let b_handle = read_card_handle(b_home)
10877 .with_context(|| format!("session {b_name} (b): read agent-card.handle"))?;
10878
10879 run(a_home, &["add", b_name, "--local-sister", "--json"])
10883 .with_context(|| format!("step 1/8: {a_name} `wire add {b_name} --local-sister`"))?;
10884
10885 std::thread::sleep(Duration::from_secs(settle_secs));
10887
10888 run(b_home, &["pull", "--json"]).with_context(|| format!("step 4/8: {b_name} `wire pull`"))?;
10891 run(b_home, &["pair-accept", &a_handle, "--json"]).with_context(|| {
10892 format!("step 5/8: {b_name} `wire pair-accept {a_handle}` (a session={a_name})")
10893 })?;
10894 run(b_home, &["push", "--json"]).with_context(|| format!("step 6/8: {b_name} `wire push`"))?;
10895
10896 std::thread::sleep(Duration::from_secs(settle_secs));
10898
10899 run(a_home, &["pull", "--json"]).with_context(|| format!("step 8/8: {a_name} `wire pull`"))?;
10901 let _ = &b_handle;
10903
10904 Ok(())
10905}
10906
10907fn cmd_session_env(name_arg: Option<&str>, as_json: bool) -> Result<()> {
10908 let name = resolve_session_name(name_arg)?;
10909 let session_home = crate::session::session_dir(&name)?;
10910 if !session_home.exists() {
10911 bail!(
10912 "no session named {name:?} on this machine. `wire session list` to enumerate, \
10913 `wire session new {name}` to create."
10914 );
10915 }
10916 if as_json {
10917 println!(
10918 "{}",
10919 serde_json::to_string(&json!({
10920 "name": name,
10921 "home_dir": session_home.to_string_lossy(),
10922 "export": format!("export WIRE_HOME={}", session_home.to_string_lossy()),
10923 }))?
10924 );
10925 } else {
10926 println!("export WIRE_HOME={}", session_home.to_string_lossy());
10927 }
10928 Ok(())
10929}
10930
10931fn cmd_session_current(as_json: bool) -> Result<()> {
10932 let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
10933 let registry = crate::session::read_registry().unwrap_or_default();
10934 let cwd_key = crate::session::normalize_cwd_key(&cwd);
10935 let name = registry
10940 .by_cwd
10941 .get(&cwd_key)
10942 .or_else(|| {
10943 registry
10944 .by_cwd
10945 .iter()
10946 .find(|(k, _)| {
10947 crate::session::normalize_cwd_key(std::path::Path::new(k)) == cwd_key
10948 })
10949 .map(|(_, v)| v)
10950 })
10951 .cloned();
10952 if as_json {
10953 println!(
10954 "{}",
10955 serde_json::to_string(&json!({
10956 "cwd": cwd_key,
10957 "session": name,
10958 }))?
10959 );
10960 } else if let Some(n) = name {
10961 println!("{n}");
10962 } else {
10963 println!("(no session registered for this cwd)");
10964 }
10965 Ok(())
10966}
10967
10968fn cmd_session_destroy(name_arg: &str, force: bool, as_json: bool) -> Result<()> {
10969 let name = crate::session::sanitize_name(name_arg);
10970 let session_home = crate::session::session_dir(&name)?;
10971 if !session_home.exists() {
10972 if as_json {
10973 println!(
10974 "{}",
10975 serde_json::to_string(&json!({
10976 "name": name,
10977 "destroyed": false,
10978 "reason": "no such session",
10979 }))?
10980 );
10981 } else {
10982 println!("no session named {name:?} — nothing to destroy.");
10983 }
10984 return Ok(());
10985 }
10986 if !force {
10987 bail!(
10988 "destroying session {name:?} would delete its keypair + state irrecoverably. \
10989 Pass --force to confirm."
10990 );
10991 }
10992
10993 let pidfile = session_home.join("state").join("wire").join("daemon.pid");
10995 if let Ok(bytes) = std::fs::read(&pidfile) {
10996 let pid: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
10997 v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
10998 } else {
10999 String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
11000 };
11001 if let Some(p) = pid {
11002 let _ = std::process::Command::new("kill")
11003 .args(["-TERM", &p.to_string()])
11004 .output();
11005 }
11006 }
11007
11008 std::fs::remove_dir_all(&session_home)
11009 .with_context(|| format!("removing session dir {session_home:?}"))?;
11010
11011 let mut registry = crate::session::read_registry().unwrap_or_default();
11013 registry.by_cwd.retain(|_, v| v != &name);
11014 crate::session::write_registry(®istry)?;
11015
11016 if as_json {
11017 println!(
11018 "{}",
11019 serde_json::to_string(&json!({
11020 "name": name,
11021 "destroyed": true,
11022 }))?
11023 );
11024 } else {
11025 println!("destroyed session {name:?}.");
11026 }
11027 Ok(())
11028}
11029
11030fn cmd_diag(action: DiagAction) -> Result<()> {
11033 let state = config::state_dir()?;
11034 let knob = state.join("diag.enabled");
11035 let log_path = state.join("diag.jsonl");
11036 match action {
11037 DiagAction::Tail { limit, json } => {
11038 let entries = crate::diag::tail(limit);
11039 if json {
11040 for e in entries {
11041 println!("{}", serde_json::to_string(&e)?);
11042 }
11043 } else if entries.is_empty() {
11044 println!("wire diag: no entries (diag may be disabled — `wire diag enable`)");
11045 } else {
11046 for e in entries {
11047 let ts = e["ts"].as_u64().unwrap_or(0);
11048 let ty = e["type"].as_str().unwrap_or("?");
11049 let pid = e["pid"].as_u64().unwrap_or(0);
11050 let payload = e["payload"].to_string();
11051 println!("[{ts}] pid={pid} {ty} {payload}");
11052 }
11053 }
11054 }
11055 DiagAction::Enable => {
11056 config::ensure_dirs()?;
11057 std::fs::write(&knob, "1")?;
11058 println!("wire diag: enabled at {knob:?}");
11059 }
11060 DiagAction::Disable => {
11061 if knob.exists() {
11062 std::fs::remove_file(&knob)?;
11063 }
11064 println!("wire diag: disabled (env WIRE_DIAG may still flip it on per-process)");
11065 }
11066 DiagAction::Status { json } => {
11067 let enabled = crate::diag::is_enabled();
11068 let size = std::fs::metadata(&log_path).map(|m| m.len()).unwrap_or(0);
11069 if json {
11070 println!(
11071 "{}",
11072 serde_json::to_string(&serde_json::json!({
11073 "enabled": enabled,
11074 "log_path": log_path,
11075 "log_size_bytes": size,
11076 }))?
11077 );
11078 } else {
11079 println!("wire diag status");
11080 println!(" enabled: {enabled}");
11081 println!(" log: {log_path:?}");
11082 println!(" log size: {size} bytes");
11083 }
11084 }
11085 }
11086 Ok(())
11087}
11088
11089fn cmd_service(action: ServiceAction) -> Result<()> {
11092 let kind = |local_relay: bool| {
11093 if local_relay {
11094 crate::service::ServiceKind::LocalRelay
11095 } else {
11096 crate::service::ServiceKind::Daemon
11097 }
11098 };
11099 let (report, as_json) = match action {
11100 ServiceAction::Install { local_relay, json } => {
11101 (crate::service::install_kind(kind(local_relay))?, json)
11102 }
11103 ServiceAction::Uninstall { local_relay, json } => {
11104 (crate::service::uninstall_kind(kind(local_relay))?, json)
11105 }
11106 ServiceAction::Status { local_relay, json } => {
11107 (crate::service::status_kind(kind(local_relay))?, json)
11108 }
11109 };
11110 if as_json {
11111 println!("{}", serde_json::to_string(&report)?);
11112 } else {
11113 println!("wire service {}", report.action);
11114 println!(" platform: {}", report.platform);
11115 println!(" unit: {}", report.unit_path);
11116 println!(" status: {}", report.status);
11117 println!(" detail: {}", report.detail);
11118 }
11119 Ok(())
11120}
11121
11122const CRATE_NAME: &str = "slancha-wire";
11125
11126fn release_asset_triple() -> Option<(&'static str, &'static str)> {
11130 #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
11131 {
11132 return Some(("x86_64-pc-windows-msvc", ".exe"));
11133 }
11134 #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
11135 {
11136 return Some(("aarch64-apple-darwin", ""));
11137 }
11138 #[cfg(all(target_os = "macos", target_arch = "x86_64"))]
11139 {
11140 return Some(("x86_64-apple-darwin", ""));
11141 }
11142 #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
11143 {
11144 return Some(("x86_64-unknown-linux-musl", ""));
11145 }
11146 #[cfg(all(target_os = "linux", target_arch = "aarch64"))]
11147 {
11148 return Some(("aarch64-unknown-linux-musl", ""));
11149 }
11150 #[allow(unreachable_code)]
11151 None
11152}
11153
11154fn fetch_latest_published_version() -> Result<String> {
11156 let url = format!("https://crates.io/api/v1/crates/{CRATE_NAME}");
11157 let client = reqwest::blocking::Client::builder()
11158 .timeout(std::time::Duration::from_secs(20))
11159 .build()?;
11160 let resp = client
11161 .get(&url)
11162 .header(
11164 "User-Agent",
11165 format!("wire/{} (self-update)", env!("CARGO_PKG_VERSION")),
11166 )
11167 .send()?;
11168 if !resp.status().is_success() {
11169 bail!("crates.io returned {} for {CRATE_NAME}", resp.status());
11170 }
11171 let v: Value = resp.json()?;
11172 v.get("crate")
11173 .and_then(|c| {
11174 c.get("max_stable_version")
11175 .or_else(|| c.get("newest_version"))
11176 })
11177 .and_then(Value::as_str)
11178 .map(str::to_string)
11179 .ok_or_else(|| anyhow!("crates.io response missing crate.max_stable_version"))
11180}
11181
11182fn version_is_newer(latest: &str, current: &str) -> bool {
11185 let parse = |s: &str| -> (u64, u64, u64) {
11186 let core = s.split('-').next().unwrap_or(s);
11187 let mut it = core.split('.').map(|p| p.parse::<u64>().unwrap_or(0));
11188 (
11189 it.next().unwrap_or(0),
11190 it.next().unwrap_or(0),
11191 it.next().unwrap_or(0),
11192 )
11193 };
11194 parse(latest) > parse(current)
11195}
11196
11197fn cargo_on_path() -> bool {
11198 std::process::Command::new("cargo")
11199 .arg("--version")
11200 .stdout(std::process::Stdio::null())
11201 .stderr(std::process::Stdio::null())
11202 .status()
11203 .map(|s| s.success())
11204 .unwrap_or(false)
11205}
11206
11207fn self_update_from_release(latest: &str) -> Result<()> {
11210 let (triple, ext) = release_asset_triple().ok_or_else(|| {
11211 anyhow!(
11212 "no prebuilt release binary for this platform — install a Rust toolchain and re-run, \
11213 or `cargo install {CRATE_NAME}`"
11214 )
11215 })?;
11216 let base =
11217 format!("https://github.com/SlanchaAi/wire/releases/download/v{latest}/wire-{triple}{ext}");
11218 let client = reqwest::blocking::Client::builder()
11219 .timeout(std::time::Duration::from_secs(120))
11220 .build()?;
11221 let resp = client
11222 .get(&base)
11223 .header("User-Agent", "wire-self-update")
11224 .send()?;
11225 if !resp.status().is_success() {
11226 bail!("downloading {base} returned {}", resp.status());
11227 }
11228 let bytes = resp.bytes()?;
11229
11230 if let Ok(sha) = client
11232 .get(format!("{base}.sha256"))
11233 .header("User-Agent", "wire-self-update")
11234 .send()
11235 && sha.status().is_success()
11236 {
11237 let expected = sha
11238 .text()?
11239 .split_whitespace()
11240 .next()
11241 .unwrap_or("")
11242 .to_string();
11243 if !expected.is_empty() {
11244 use sha2::{Digest, Sha256};
11245 let mut h = Sha256::new();
11246 h.update(&bytes);
11247 let actual = hex::encode(h.finalize());
11248 if expected != actual {
11249 bail!(
11250 "SHA-256 mismatch — expected {expected}, got {actual} (aborting, binary NOT replaced)"
11251 );
11252 }
11253 }
11254 }
11255
11256 let exe = std::env::current_exe().context("locating current exe")?;
11257 let dir = exe
11258 .parent()
11259 .ok_or_else(|| anyhow!("current exe has no parent dir"))?;
11260 let tmp = dir.join(format!(".wire-update-{}", std::process::id()));
11261 std::fs::write(&tmp, &bytes).with_context(|| format!("writing {tmp:?}"))?;
11262 #[cfg(unix)]
11263 {
11264 use std::os::unix::fs::PermissionsExt;
11265 let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755));
11266 std::fs::rename(&tmp, &exe).with_context(|| format!("replacing {exe:?}"))?;
11269 }
11270 #[cfg(windows)]
11271 {
11272 let old = exe.with_extension("old");
11275 let _ = std::fs::remove_file(&old);
11276 std::fs::rename(&exe, &old)
11277 .with_context(|| format!("renaming running exe {exe:?} aside"))?;
11278 std::fs::rename(&tmp, &exe).with_context(|| format!("installing new exe at {exe:?}"))?;
11279 }
11280 Ok(())
11281}
11282
11283struct UpdateOutcome {
11285 current: String,
11286 latest: String,
11287 available: bool,
11289 installed: bool,
11291 via: Option<&'static str>,
11293}
11294
11295fn self_update_step(install: bool) -> Result<UpdateOutcome> {
11299 let current = env!("CARGO_PKG_VERSION").to_string();
11300 let latest = fetch_latest_published_version().context("checking crates.io for latest wire")?;
11301 let available = version_is_newer(&latest, ¤t);
11302 if !install || !available {
11303 return Ok(UpdateOutcome {
11304 current,
11305 latest,
11306 available,
11307 installed: false,
11308 via: None,
11309 });
11310 }
11311 let via = if cargo_on_path() {
11312 eprintln!(
11313 "wire upgrade: {current} → {latest} — installing via `cargo install {CRATE_NAME}` …"
11314 );
11315 let status = std::process::Command::new("cargo")
11316 .args([
11317 "install",
11318 CRATE_NAME,
11319 "--version",
11320 &latest,
11321 "--force",
11322 "--locked",
11323 ])
11324 .status()
11325 .context("running cargo install")?;
11326 if !status.success() {
11327 bail!("`cargo install {CRATE_NAME}` failed");
11328 }
11329 "cargo install"
11330 } else {
11331 eprintln!(
11332 "wire upgrade: {current} → {latest} — no `cargo` on PATH, downloading the prebuilt release binary …"
11333 );
11334 self_update_from_release(&latest)?;
11335 "prebuilt release binary"
11336 };
11337 Ok(UpdateOutcome {
11338 current,
11339 latest,
11340 available,
11341 installed: true,
11342 via: Some(via),
11343 })
11344}
11345
11346fn upgrade_kill_set(
11367 my_pid: Option<u32>,
11368 found_daemon_pids: &[u32],
11369 owned_session_pids: &std::collections::HashSet<u32>,
11370) -> Vec<u32> {
11371 let mut k: Vec<u32> = Vec::new();
11372 if let Some(p) = my_pid {
11373 k.push(p);
11374 }
11375 for &p in found_daemon_pids {
11376 if !owned_session_pids.contains(&p) && Some(p) != my_pid {
11377 k.push(p); }
11379 }
11380 k.sort_unstable();
11381 k.dedup();
11382 k
11383}
11384
11385#[derive(Debug, Clone)]
11392struct PathWireBinary {
11393 path: std::path::PathBuf,
11396 canonical: std::path::PathBuf,
11400 sha256: Option<String>,
11403 mtime: Option<std::time::SystemTime>,
11405 path_index: usize,
11408 is_current_exe: bool,
11414}
11415
11416impl PathWireBinary {
11417 fn is_active(&self) -> bool {
11419 self.path_index == 0
11420 }
11421 fn sha256_short(&self) -> String {
11424 self.sha256
11425 .as_deref()
11426 .map(|s| s[..s.len().min(8)].to_string())
11427 .unwrap_or_else(|| "????????".to_string())
11428 }
11429 fn mtime_display(&self) -> String {
11431 let Some(ts) = self.mtime else {
11432 return "?".to_string();
11433 };
11434 let secs = match ts.duration_since(std::time::UNIX_EPOCH) {
11435 Ok(d) => d.as_secs() as i64,
11436 Err(_) => return "?".to_string(),
11437 };
11438 time::OffsetDateTime::from_unix_timestamp(secs)
11439 .ok()
11440 .and_then(|dt| {
11441 dt.format(&time::format_description::well_known::Rfc3339)
11442 .ok()
11443 })
11444 .unwrap_or_else(|| "?".to_string())
11445 }
11446}
11447
11448fn sha256_file(p: &std::path::Path) -> Result<String> {
11450 use sha2::{Digest, Sha256};
11451 let mut f = std::fs::File::open(p).with_context(|| format!("opening {}", p.display()))?;
11452 let mut h = Sha256::new();
11453 std::io::copy(&mut f, &mut h).with_context(|| format!("hashing {}", p.display()))?;
11454 Ok(hex::encode(h.finalize()))
11455}
11456
11457fn enumerate_path_wire_binaries() -> Vec<PathWireBinary> {
11471 let path = std::env::var("PATH").unwrap_or_default();
11472 let current_exe_canon: Option<std::path::PathBuf> = std::env::current_exe()
11473 .ok()
11474 .and_then(|p| p.canonicalize().ok());
11475 enumerate_path_wire_binaries_from(&path, current_exe_canon.as_deref())
11476}
11477
11478fn enumerate_path_wire_binaries_from(
11483 path: &str,
11484 current_exe_canon: Option<&std::path::Path>,
11485) -> Vec<PathWireBinary> {
11486 if path.is_empty() {
11487 return Vec::new();
11488 }
11489 let separator = if cfg!(windows) { ';' } else { ':' };
11494 let names: &[&str] = if cfg!(windows) {
11495 &["wire.exe", "wire"]
11499 } else {
11500 &["wire"]
11501 };
11502
11503 let mut seen: std::collections::HashSet<std::path::PathBuf> = std::collections::HashSet::new();
11504 let mut out: Vec<PathWireBinary> = Vec::new();
11505 for dir in path.split(separator) {
11506 if dir.is_empty() {
11507 continue;
11508 }
11509 for name in names {
11510 let candidate = std::path::PathBuf::from(dir).join(name);
11511 if !candidate.is_file() {
11514 continue;
11515 }
11516 let canon = candidate
11517 .canonicalize()
11518 .unwrap_or_else(|_| candidate.clone());
11519 if !seen.insert(canon.clone()) {
11520 break;
11523 }
11524 let meta = std::fs::metadata(&canon).ok();
11525 let mtime = meta.as_ref().and_then(|m| m.modified().ok());
11526 let sha256 = sha256_file(&canon).ok();
11527 let is_current_exe = current_exe_canon
11528 .map(|c| c == canon.as_path())
11529 .unwrap_or(false);
11530 let path_index = out.len();
11531 out.push(PathWireBinary {
11532 path: candidate,
11533 canonical: canon,
11534 sha256,
11535 mtime,
11536 path_index,
11537 is_current_exe,
11538 });
11539 break;
11542 }
11543 }
11544 out
11545}
11546
11547fn path_shadow_warning(bins: &[PathWireBinary]) -> Option<String> {
11559 let any_current = bins.iter().any(|b| b.is_current_exe);
11560 let multi = bins.len() >= 2;
11561 let off_path = !bins.is_empty() && !any_current;
11562 let none_on_path = bins.is_empty();
11563 if !multi && !off_path && !none_on_path {
11564 return None;
11565 }
11566 let mut out = String::new();
11567 if multi {
11568 out.push_str(&format!(
11569 "WARN: {} distinct `wire` binaries on PATH — older entries can shadow your fresh install:\n",
11570 bins.len()
11571 ));
11572 for b in bins {
11573 let mut tags: Vec<&str> = Vec::new();
11574 if b.is_active() {
11575 tags.push("ACTIVE (bare `wire` resolves here)");
11576 }
11577 if b.is_current_exe {
11578 tags.push("THIS upgrade ran against this binary");
11579 }
11580 let tag_str = if tags.is_empty() {
11581 String::new()
11582 } else {
11583 format!(" ← {}", tags.join("; "))
11584 };
11585 out.push_str(&format!(
11586 " [{}] {} (sha256:{} mtime:{}){}\n",
11587 b.path_index,
11588 b.path.display(),
11589 b.sha256_short(),
11590 b.mtime_display(),
11591 tag_str,
11592 ));
11593 }
11594 if !any_current {
11595 out.push_str(
11596 " NOTE: none of the PATH-resident binaries is the one running this `wire upgrade`.\n",
11597 );
11598 out.push_str(
11599 " Your upgrade will NOT affect bare `wire` calls in shells, scripts, or peer agents.\n",
11600 );
11601 } else if !bins[0].is_current_exe {
11602 out.push_str(
11603 " Bare `wire` calls (shells, scripts, daemons, peer agents) will use the\n",
11604 );
11605 out.push_str(
11606 " ACTIVE binary [0], NOT the one you just upgraded. Recommended fixes:\n",
11607 );
11608 out.push_str(&format!(
11609 " - rm {} (or symlink it to the upgraded binary)\n",
11610 bins[0].path.display(),
11611 ));
11612 out.push_str(
11613 " - or reorder PATH so the upgraded binary's directory precedes the active one\n",
11614 );
11615 out.push_str(" Verify with: which -a wire\n");
11616 }
11617 } else if off_path {
11618 let active = &bins[0];
11620 out.push_str("WARN: this `wire upgrade` is running against an off-PATH binary;\n");
11621 out.push_str(&format!(
11622 " bare `wire` resolves to {} (sha256:{}),\n",
11623 active.path.display(),
11624 active.sha256_short(),
11625 ));
11626 out.push_str(
11627 " which was NOT touched by this upgrade. Shells, scripts, and peer agents\n",
11628 );
11629 out.push_str(" will continue to invoke the old binary.\n");
11630 } else if none_on_path {
11631 out.push_str("WARN: no `wire` binary on PATH; bare `wire` will fail in future shells.\n");
11632 out.push_str(" This upgrade ran against an absolute-path invocation only.\n");
11633 }
11634 Some(out.trim_end().to_string())
11635}
11636
11637#[cfg(test)]
11638mod upgrade_tests {
11639 use super::*;
11640 use std::collections::HashSet;
11641
11642 #[test]
11643 fn upgrade_kill_set_is_session_scoped() {
11644 let owned: HashSet<u32> = [100, 200].into_iter().collect();
11646 let k = upgrade_kill_set(Some(100), &[100, 200, 999], &owned);
11648 assert!(k.contains(&100), "must kill my own daemon (to replace it)");
11649 assert!(k.contains(&999), "must sweep a true orphan");
11650 assert!(!k.contains(&200), "must SPARE a sibling session's daemon");
11651
11652 assert_eq!(
11656 upgrade_kill_set(Some(100), &[], &owned),
11657 vec![100],
11658 "own daemon killed even when the process scan is empty"
11659 );
11660
11661 assert_eq!(upgrade_kill_set(None, &[999], &HashSet::new()), vec![999]);
11663 }
11664
11665 fn write_fake_wire(dir: &std::path::Path, body: &[u8]) -> std::path::PathBuf {
11673 use std::io::Write;
11674 let p = dir.join("wire");
11675 let mut f = std::fs::File::create(&p).expect("create fake wire");
11676 f.write_all(body).expect("write fake wire");
11677 drop(f);
11678 #[cfg(unix)]
11679 {
11680 use std::os::unix::fs::PermissionsExt;
11681 let mut perm = std::fs::metadata(&p).unwrap().permissions();
11682 perm.set_mode(0o755);
11683 std::fs::set_permissions(&p, perm).unwrap();
11684 }
11685 p
11686 }
11687
11688 #[test]
11689 #[cfg_attr(windows, ignore = "PATH separator + .exe semantics differ")]
11690 fn enumerate_finds_no_binaries_when_path_empty() {
11691 let bins = enumerate_path_wire_binaries_from("", None);
11692 assert!(
11693 bins.is_empty(),
11694 "empty PATH yields no binaries, got {bins:?}"
11695 );
11696 }
11697
11698 #[test]
11699 #[cfg_attr(windows, ignore = "PATH separator + .exe semantics differ")]
11700 fn enumerate_detects_two_distinct_binaries_in_path_order() {
11701 let d1 = tempfile::tempdir().unwrap();
11702 let d2 = tempfile::tempdir().unwrap();
11703 let p1 = write_fake_wire(d1.path(), b"#!/bin/sh\necho A\n");
11704 let p2 = write_fake_wire(d2.path(), b"#!/bin/sh\necho B\n");
11705 let path = format!("{}:{}", d1.path().display(), d2.path().display());
11706
11707 let bins = enumerate_path_wire_binaries_from(&path, None);
11708 assert_eq!(bins.len(), 2, "expected two distinct binaries: {bins:?}");
11709 assert_eq!(bins[0].path_index, 0);
11710 assert_eq!(bins[1].path_index, 1);
11711 assert!(bins[0].is_active(), "first PATH entry is active");
11712 assert!(!bins[1].is_active(), "second PATH entry is not active");
11713 assert_ne!(
11715 bins[0].sha256, bins[1].sha256,
11716 "distinct contents must hash differently"
11717 );
11718 assert_eq!(bins[0].path, p1);
11720 assert_eq!(bins[1].path, p2);
11721 }
11722
11723 #[test]
11724 #[cfg_attr(windows, ignore = "PATH separator + symlink semantics differ")]
11725 fn enumerate_collapses_symlink_chains_to_one_entry() {
11726 let real_dir = tempfile::tempdir().unwrap();
11727 let link_dir = tempfile::tempdir().unwrap();
11728 let real = write_fake_wire(real_dir.path(), b"#!/bin/sh\necho real\n");
11729 let link = link_dir.path().join("wire");
11730 #[cfg(unix)]
11731 std::os::unix::fs::symlink(&real, &link).unwrap();
11732
11733 let path = format!(
11737 "{}:{}",
11738 link_dir.path().display(),
11739 real_dir.path().display()
11740 );
11741 let bins = enumerate_path_wire_binaries_from(&path, None);
11742 assert_eq!(
11743 bins.len(),
11744 1,
11745 "symlink chain must collapse to a single entry: {bins:?}"
11746 );
11747 assert!(bins[0].is_active());
11748 assert_eq!(bins[0].path, link);
11750 assert_eq!(bins[0].canonical, real.canonicalize().unwrap());
11751 }
11752
11753 #[test]
11754 #[cfg_attr(windows, ignore = "PATH separator + .exe semantics differ")]
11755 fn shadow_warning_off_path_when_current_exe_not_on_path() {
11756 let d = tempfile::tempdir().unwrap();
11759 write_fake_wire(d.path(), b"#!/bin/sh\necho only\n");
11760 let elsewhere = tempfile::tempdir().unwrap();
11761 let cur = elsewhere.path().join("not-on-path-wire");
11762 let bins = enumerate_path_wire_binaries_from(&d.path().display().to_string(), Some(&cur));
11763 assert_eq!(bins.len(), 1);
11764 assert!(!bins[0].is_current_exe);
11765 let warn = path_shadow_warning(&bins).expect("off-path single bin must warn");
11766 assert!(
11767 warn.contains("off-PATH binary"),
11768 "off-path WARN must mention off-PATH; got: {warn}"
11769 );
11770 }
11771
11772 #[test]
11773 fn shadow_warning_fires_when_no_binaries_at_all() {
11774 let bins: Vec<PathWireBinary> = Vec::new();
11775 let warn = path_shadow_warning(&bins).expect("empty must warn");
11776 assert!(warn.contains("no `wire` binary on PATH"), "got: {warn}");
11777 }
11778
11779 #[test]
11780 #[cfg_attr(windows, ignore = "PATH separator differs")]
11781 fn shadow_warning_multi_binaries_names_active_and_recommends_fix() {
11782 let d1 = tempfile::tempdir().unwrap();
11783 let d2 = tempfile::tempdir().unwrap();
11784 write_fake_wire(d1.path(), b"published\n");
11785 write_fake_wire(d2.path(), b"head\n");
11786 let path = format!("{}:{}", d1.path().display(), d2.path().display());
11787 let bins = enumerate_path_wire_binaries_from(&path, None);
11788 let warn = path_shadow_warning(&bins).expect("two distinct bins must warn");
11789 assert!(warn.contains("2 distinct"), "got: {warn}");
11790 assert!(warn.contains("ACTIVE"), "must mark the active binary");
11791 assert!(
11792 warn.contains("which -a wire") || warn.contains("none of the PATH-resident"),
11793 "must guide the operator to a fix; got: {warn}"
11794 );
11795 }
11796}
11797
11798fn cmd_upgrade(check_only: bool, local: bool, as_json: bool) -> Result<()> {
11799 let update: Option<UpdateOutcome> = if local {
11805 None
11806 } else {
11807 match self_update_step(!check_only) {
11808 Ok(o) => Some(o),
11809 Err(e) => {
11810 if !check_only {
11811 eprintln!("wire upgrade: update check skipped — {e:#}");
11812 }
11813 None
11814 }
11815 }
11816 };
11817 if let Some(o) = &update
11818 && o.installed
11819 {
11820 eprintln!(
11821 "wire upgrade: installed {} (was {}, via {}); restarting the daemon on the new binary.",
11822 o.latest,
11823 o.current,
11824 o.via.unwrap_or("self-update")
11825 );
11826 }
11827
11828 let daemon_pids: Vec<u32> = crate::platform::find_processes_by_cmdline("wire daemon");
11837 let relay_pids: Vec<u32> = crate::platform::find_processes_by_cmdline("wire relay-server");
11838 let mcp_pids: Vec<u32> = crate::platform::find_processes_by_cmdline("wire mcp");
11846 let running_pids: Vec<u32> = daemon_pids
11847 .iter()
11848 .chain(relay_pids.iter())
11849 .copied()
11850 .collect();
11851
11852 let record = crate::ensure_up::read_pid_record("daemon");
11854 let recorded_version: Option<String> = match &record {
11855 crate::ensure_up::PidRecord::Json(d) => Some(d.version.clone()),
11856 crate::ensure_up::PidRecord::LegacyInt(_) => Some("<pre-0.5.11>".to_string()),
11857 _ => None,
11858 };
11859 let cli_version = env!("CARGO_PKG_VERSION").to_string();
11860
11861 let my_daemon_pid = record.pid();
11875 let owned_session_pids: std::collections::HashSet<u32> = crate::session::list_sessions()
11876 .unwrap_or_default()
11877 .iter()
11878 .filter_map(|s| crate::session::session_daemon_pid(&s.home_dir))
11879 .collect();
11880 let kill_set = upgrade_kill_set(my_daemon_pid, &daemon_pids, &owned_session_pids);
11881 if check_only {
11884 let sessions_with_daemons: Vec<String> = crate::session::list_sessions()
11886 .unwrap_or_default()
11887 .iter()
11888 .filter(|s| s.daemon_running)
11889 .map(|s| s.name.clone())
11890 .collect();
11891 let path_bins = enumerate_path_wire_binaries();
11892 let path_dupes: Vec<String> = path_bins
11893 .iter()
11894 .map(|b| b.canonical.to_string_lossy().into_owned())
11895 .collect();
11896 let path_binaries_detail: Vec<serde_json::Value> = path_bins
11897 .iter()
11898 .map(|b| {
11899 json!({
11900 "path": b.path.to_string_lossy(),
11901 "canonical": b.canonical.to_string_lossy(),
11902 "sha256": b.sha256,
11903 "mtime_rfc3339": b.mtime.map(|_| b.mtime_display()),
11904 "path_index": b.path_index,
11905 "is_active": b.is_active(),
11906 "is_current_exe": b.is_current_exe,
11907 })
11908 })
11909 .collect();
11910 let path_warning_check = path_shadow_warning(&path_bins);
11911 let installed_service_kinds: Vec<&'static str> = [
11914 (crate::service::ServiceKind::Daemon, "daemon"),
11915 (crate::service::ServiceKind::LocalRelay, "local-relay"),
11916 ]
11917 .into_iter()
11918 .filter_map(|(k, label)| {
11919 crate::service::status_kind(k)
11920 .ok()
11921 .filter(|r| r.status != "absent")
11922 .map(|_| label)
11923 })
11924 .collect();
11925 let (update_latest, update_available) = match &update {
11926 Some(o) => (Some(o.latest.clone()), o.available),
11927 None => (None, false),
11928 };
11929 let report = json!({
11930 "running_pids": running_pids,
11931 "running_daemons": daemon_pids,
11932 "running_relay_servers": relay_pids,
11933 "running_mcp_servers": mcp_pids,
11938 "would_warn_stale_mcp_servers": !mcp_pids.is_empty(),
11939 "pidfile_version": recorded_version,
11940 "cli_version": cli_version,
11941 "latest_published": update_latest,
11942 "update_available": update_available,
11943 "would_kill": kill_set,
11944 "would_refresh_services": installed_service_kinds,
11945 "session_daemons_running": sessions_with_daemons,
11946 "path_binaries": path_dupes,
11947 "path_binaries_detail": path_binaries_detail,
11948 "path_duplicate_warning": path_dupes.len() > 1,
11949 "path_warning": path_warning_check,
11950 });
11951 if as_json {
11952 println!("{}", serde_json::to_string(&report)?);
11953 } else {
11954 println!("wire upgrade --check");
11955 println!(" cli version: {cli_version}");
11956 match (&update_latest, update_available) {
11957 (Some(l), true) => println!(" latest published: {l} (UPDATE AVAILABLE)"),
11958 (Some(l), false) => println!(" latest published: {l} (up to date)"),
11959 (None, _) => println!(" latest published: (crates.io check skipped)"),
11960 }
11961 println!(
11962 " pidfile version: {}",
11963 recorded_version.as_deref().unwrap_or("(missing)")
11964 );
11965 if running_pids.is_empty() {
11966 println!(" running daemons: none");
11967 println!(" running relays: none");
11968 } else {
11969 if daemon_pids.is_empty() {
11970 println!(" running daemons: none");
11971 } else {
11972 let p: Vec<String> = daemon_pids.iter().map(|p| p.to_string()).collect();
11973 println!(" running daemons: pids {}", p.join(", "));
11974 }
11975 if relay_pids.is_empty() {
11976 println!(" running relays: none");
11977 } else {
11978 let p: Vec<String> = relay_pids.iter().map(|p| p.to_string()).collect();
11979 println!(" running relays: pids {}", p.join(", "));
11980 }
11981 println!(" would kill all + spawn fresh");
11982 }
11983 if !mcp_pids.is_empty() {
11989 let p: Vec<String> = mcp_pids.iter().map(|p| p.to_string()).collect();
11990 println!(
11991 " wire mcp servers: pids {} (NOT killed; each Claude tab must `/mcp` reconnect to load new binary)",
11992 p.join(", ")
11993 );
11994 }
11995 if !installed_service_kinds.is_empty() {
11996 println!(
11997 " would refresh: {} installed service unit(s) → new binary path",
11998 installed_service_kinds.join(", ")
11999 );
12000 }
12001 if !sessions_with_daemons.is_empty() {
12002 println!(
12003 " session daemons: {} (would respawn under new binary)",
12004 sessions_with_daemons.join(", ")
12005 );
12006 }
12007 if let Some(w) = &path_warning_check {
12008 println!(" PATH check:");
12009 for line in w.lines() {
12010 println!(" {line}");
12011 }
12012 }
12013 }
12014 return Ok(());
12015 }
12016
12017 for pid in &kill_set {
12029 let _ = crate::platform::kill_process(*pid, false); }
12031 if !kill_set.is_empty() {
12032 let deadline = std::time::Instant::now() + std::time::Duration::from_millis(800);
12034 while std::time::Instant::now() < deadline && kill_set.iter().any(|p| process_alive_pid(*p))
12035 {
12036 std::thread::sleep(std::time::Duration::from_millis(50));
12037 }
12038 for pid in &kill_set {
12041 if process_alive_pid(*pid) {
12042 let _ = crate::platform::kill_process(*pid, true);
12043 }
12044 }
12045 std::thread::sleep(std::time::Duration::from_millis(200)); }
12047 let killed: Vec<u32> = kill_set
12049 .iter()
12050 .copied()
12051 .filter(|p| !process_alive_pid(*p))
12052 .collect();
12053
12054 let pidfile = config::state_dir()?.join("daemon.pid");
12057 if pidfile.exists() {
12058 let _ = std::fs::remove_file(&pidfile);
12059 }
12060
12061 let path_bins = enumerate_path_wire_binaries();
12073 let path_dupes: Vec<String> = path_bins
12074 .iter()
12075 .map(|b| b.canonical.to_string_lossy().into_owned())
12076 .collect();
12077 let path_binaries_detail: Vec<Value> = path_bins
12078 .iter()
12079 .map(|b| {
12080 json!({
12081 "path": b.path.to_string_lossy(),
12082 "canonical": b.canonical.to_string_lossy(),
12083 "sha256": b.sha256,
12084 "mtime_rfc3339": b.mtime.map(|_| b.mtime_display()),
12085 "path_index": b.path_index,
12086 "is_active": b.is_active(),
12087 "is_current_exe": b.is_current_exe,
12088 })
12089 })
12090 .collect();
12091 let path_warning = path_shadow_warning(&path_bins);
12092
12093 let mut service_refreshes: Vec<Value> = Vec::new();
12107 for kind in [
12108 crate::service::ServiceKind::Daemon,
12109 crate::service::ServiceKind::LocalRelay,
12110 ] {
12111 let already_installed = crate::service::status_kind(kind)
12112 .map(|r| r.status != "absent")
12113 .unwrap_or(false);
12114 if !already_installed {
12115 continue;
12116 }
12117 match crate::service::install_kind(kind) {
12118 Ok(rep) => service_refreshes.push(json!({
12119 "kind": rep.kind,
12120 "platform": rep.platform,
12121 "status": rep.status,
12122 "unit_path": rep.unit_path,
12123 "action": "refreshed",
12124 })),
12125 Err(e) => service_refreshes.push(json!({
12126 "kind": format!("{kind:?}"),
12127 "action": "refresh_failed",
12128 "error": format!("{e:#}"),
12129 })),
12130 }
12131 }
12132
12133 let spawned = crate::ensure_up::ensure_daemon_running()?;
12139
12140 let session_respawns: Vec<Value> = Vec::new();
12145
12146 let new_record = crate::ensure_up::read_pid_record("daemon");
12147 let new_pid = new_record.pid();
12148 let new_version: Option<String> = if let crate::ensure_up::PidRecord::Json(d) = &new_record {
12149 Some(d.version.clone())
12150 } else {
12151 None
12152 };
12153
12154 if as_json {
12155 println!(
12156 "{}",
12157 serde_json::to_string(&json!({
12158 "killed": killed,
12159 "found_daemons": daemon_pids,
12160 "spared_relay_servers": relay_pids,
12161 "stale_mcp_server_pids": mcp_pids,
12169 "stale_mcp_warning": if mcp_pids.is_empty() {
12170 Value::Null
12171 } else {
12172 json!(format!(
12173 "{} `wire mcp` server subprocess(es) still on pre-upgrade code; each Claude tab must `/mcp` reconnect to pick up the new binary",
12174 mcp_pids.len()
12175 ))
12176 },
12177 "service_refreshes": service_refreshes,
12178 "spawned_fresh_daemon": spawned,
12179 "new_pid": new_pid,
12180 "new_version": new_version,
12181 "cli_version": cli_version,
12182 "session_respawns": session_respawns,
12183 "path_binaries": path_dupes,
12184 "path_binaries_detail": path_binaries_detail,
12185 "path_warning": path_warning,
12186 }))?
12187 );
12188 } else {
12189 if killed.is_empty() {
12190 println!("wire upgrade: no stale wire processes running");
12191 } else {
12192 let killed_list = killed
12193 .iter()
12194 .map(|p| p.to_string())
12195 .collect::<Vec<_>>()
12196 .join(", ");
12197 if relay_pids.is_empty() {
12202 println!(
12203 "wire upgrade: killed {} daemon(s) [{killed_list}]",
12204 killed.len()
12205 );
12206 } else {
12207 let relay_list = relay_pids
12208 .iter()
12209 .map(|p| p.to_string())
12210 .collect::<Vec<_>>()
12211 .join(", ");
12212 println!(
12213 "wire upgrade: killed {} daemon(s) [{killed_list}]; spared {} shared relay-server(s) [{relay_list}]",
12214 killed.len(),
12215 relay_pids.len()
12216 );
12217 }
12218 }
12219 if !service_refreshes.is_empty() {
12220 println!(
12221 "wire upgrade: refreshed {} installed service unit(s) to point at the new binary:",
12222 service_refreshes.len()
12223 );
12224 for r in &service_refreshes {
12225 let kind = r.get("kind").and_then(Value::as_str).unwrap_or("?");
12226 let action = r.get("action").and_then(Value::as_str).unwrap_or("?");
12227 let status = r.get("status").and_then(Value::as_str).unwrap_or("");
12228 let platform = r.get("platform").and_then(Value::as_str).unwrap_or("");
12229 if action == "refreshed" {
12230 println!(" - {kind}: {action} ({status}, {platform})");
12231 } else {
12232 let err = r.get("error").and_then(Value::as_str).unwrap_or("");
12233 println!(" - {kind}: {action} ({err})");
12234 }
12235 }
12236 }
12237 if spawned {
12238 println!(
12239 "wire upgrade: spawned fresh daemon (pid {} v{})",
12240 new_pid
12241 .map(|p| p.to_string())
12242 .unwrap_or_else(|| "?".to_string()),
12243 new_version.as_deref().unwrap_or(&cli_version),
12244 );
12245 } else {
12246 println!("wire upgrade: daemon was already running on current binary");
12247 }
12248 if !session_respawns.is_empty() {
12249 println!(
12250 "wire upgrade: refreshed {} session daemon(s):",
12251 session_respawns.len()
12252 );
12253 for r in &session_respawns {
12254 let h = r["session_home"].as_str().unwrap_or("?");
12255 let s = r["status"].as_str().unwrap_or("?");
12256 let label = std::path::Path::new(h)
12257 .file_name()
12258 .map(|f| f.to_string_lossy().into_owned())
12259 .unwrap_or_else(|| h.to_string());
12260 println!(" {label:<24} {s}");
12261 }
12262 }
12263 if let Some(msg) = &path_warning {
12264 eprintln!("wire upgrade: {msg}");
12265 }
12266 if !mcp_pids.is_empty() {
12275 let p: Vec<String> = mcp_pids.iter().map(|p| p.to_string()).collect();
12276 eprintln!(
12277 "wire upgrade: NOTE — {} `wire mcp` server subprocess(es) [{}] still on pre-upgrade code (Claude Code / Claude.app pin these at session start). Each Claude tab must `/mcp` reconnect (or restart the host app) to pick up the new binary.",
12278 mcp_pids.len(),
12279 p.join(", ")
12280 );
12281 }
12282 }
12283 Ok(())
12284}
12285
12286fn json_default(explicit: bool) -> bool {
12296 if explicit {
12297 return true;
12298 }
12299 if std::env::var("WIRE_NO_AUTO_JSON").is_ok() {
12300 return false;
12301 }
12302 use std::io::IsTerminal;
12303 !std::io::stdout().is_terminal()
12304}
12305
12306fn process_alive_pid(pid: u32) -> bool {
12307 crate::platform::process_alive(pid)
12312}
12313
12314fn levenshtein_ci(a: &str, b: &str) -> usize {
12320 let a: Vec<char> = a.to_ascii_lowercase().chars().collect();
12321 let b: Vec<char> = b.to_ascii_lowercase().chars().collect();
12322 let (a, b) = if a.len() < b.len() { (a, b) } else { (b, a) };
12323 let (m, n) = (a.len(), b.len());
12324 if m == 0 {
12325 return n;
12326 }
12327 let mut prev: Vec<usize> = (0..=m).collect();
12328 let mut curr = vec![0usize; m + 1];
12329 for j in 1..=n {
12330 curr[0] = j;
12331 for i in 1..=m {
12332 let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
12333 curr[i] = std::cmp::min(
12334 std::cmp::min(curr[i - 1] + 1, prev[i] + 1),
12335 prev[i - 1] + cost,
12336 );
12337 }
12338 std::mem::swap(&mut prev, &mut curr);
12339 }
12340 prev[m]
12341}
12342
12343pub fn closest_candidates(
12347 needle: &str,
12348 pool: &[String],
12349 max_distance: usize,
12350 max_results: usize,
12351) -> Vec<String> {
12352 let mut scored: Vec<(usize, &String)> = pool
12353 .iter()
12354 .map(|c| (levenshtein_ci(needle, c), c))
12355 .filter(|(d, _)| *d <= max_distance)
12356 .collect();
12357 scored.sort_by_key(|(d, _)| *d);
12358 scored
12359 .into_iter()
12360 .take(max_results)
12361 .map(|(_, c)| c.clone())
12362 .collect()
12363}
12364
12365fn known_local_names() -> Vec<String> {
12370 let mut names: Vec<String> = Vec::new();
12371 if let Ok(trust) = config::read_trust() {
12372 if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
12378 for (handle, agent) in agents {
12379 names.push(handle.clone());
12380 if let Some(did) = agent.get("did").and_then(Value::as_str) {
12381 let ch = crate::character::Character::from_did(did);
12382 names.push(ch.nickname);
12383 }
12384 }
12385 }
12386 }
12387 if let Ok(sessions) = crate::session::list_sessions() {
12388 for s in sessions {
12389 names.push(s.name.clone());
12390 if let Some(h) = &s.handle {
12391 names.push(h.clone());
12392 }
12393 if let Some(ch) = &s.character {
12394 names.push(ch.nickname.clone());
12395 }
12396 }
12397 }
12398 names.sort();
12399 names.dedup();
12400 names
12401}
12402
12403fn deprecation_warn(verb: &str, replacement: &str, json_mode: bool) {
12411 if json_mode {
12412 return;
12413 }
12414 let key = format!("WIRE_DEPRECATION_NAGGED_{}", verb.replace('-', "_"));
12422 if std::env::var(&key).is_ok() {
12423 return;
12424 }
12425 unsafe {
12429 std::env::set_var(&key, "1");
12430 }
12431 eprintln!(
12432 "wire {verb}: DEPRECATED in v0.9 — use `wire {replacement}`. \
12433 Will be removed in v1.0 (target 2026-Q3). \
12434 Suppress: set WIRE_DEPRECATION_NAGGED_{}=1.",
12435 verb.replace('-', "_")
12436 );
12437}
12438
12439#[derive(Clone, Debug, serde::Serialize)]
12443pub struct DoctorCheck {
12444 pub id: String,
12447 pub status: String,
12449 pub detail: String,
12451 #[serde(skip_serializing_if = "Option::is_none")]
12453 pub fix: Option<String>,
12454}
12455
12456impl DoctorCheck {
12457 fn pass(id: &str, detail: impl Into<String>) -> Self {
12458 Self {
12459 id: id.into(),
12460 status: "PASS".into(),
12461 detail: detail.into(),
12462 fix: None,
12463 }
12464 }
12465 fn warn(id: &str, detail: impl Into<String>, fix: impl Into<String>) -> Self {
12466 Self {
12467 id: id.into(),
12468 status: "WARN".into(),
12469 detail: detail.into(),
12470 fix: Some(fix.into()),
12471 }
12472 }
12473 fn fail(id: &str, detail: impl Into<String>, fix: impl Into<String>) -> Self {
12474 Self {
12475 id: id.into(),
12476 status: "FAIL".into(),
12477 detail: detail.into(),
12478 fix: Some(fix.into()),
12479 }
12480 }
12481}
12482
12483fn cmd_doctor(as_json: bool, recent_rejections: usize) -> Result<()> {
12488 let checks: Vec<DoctorCheck> = vec![
12489 check_daemon_health(),
12490 check_daemon_pid_consistency(),
12491 check_relay_reachable(),
12492 check_pair_rejections(recent_rejections),
12493 check_cursor_progress(),
12494 check_peer_staleness(7),
12495 check_and_heal_self_userinfo_endpoints(),
12496 ];
12497
12498 let fails = checks.iter().filter(|c| c.status == "FAIL").count();
12499 let warns = checks.iter().filter(|c| c.status == "WARN").count();
12500
12501 if as_json {
12502 println!(
12503 "{}",
12504 serde_json::to_string(&json!({
12505 "checks": checks,
12506 "fail_count": fails,
12507 "warn_count": warns,
12508 "ok": fails == 0,
12509 }))?
12510 );
12511 } else {
12512 println!("wire doctor — {} checks", checks.len());
12513 for c in &checks {
12514 let bullet = match c.status.as_str() {
12515 "PASS" => "✓",
12516 "WARN" => "!",
12517 "FAIL" => "✗",
12518 _ => "?",
12519 };
12520 println!(" {bullet} [{}] {}: {}", c.status, c.id, c.detail);
12521 if let Some(fix) = &c.fix {
12522 println!(" fix: {fix}");
12523 }
12524 }
12525 println!();
12526 if fails == 0 && warns == 0 {
12527 println!("ALL GREEN");
12528 } else {
12529 println!("{fails} FAIL, {warns} WARN");
12530 }
12531 }
12532
12533 if fails > 0 {
12534 std::process::exit(1);
12535 }
12536 Ok(())
12537}
12538
12539fn check_daemon_health() -> DoctorCheck {
12546 let snap = crate::ensure_up::daemon_liveness();
12552 let pgrep_pids = &snap.pgrep_pids;
12553 let pidfile_pid = snap.pidfile_pid;
12554 let pidfile_alive = snap.pidfile_alive;
12555 let orphan_pids = &snap.orphan_pids;
12556
12557 let fmt_pids = |xs: &[u32]| -> String {
12558 xs.iter()
12559 .map(|p| p.to_string())
12560 .collect::<Vec<_>>()
12561 .join(", ")
12562 };
12563
12564 match (pgrep_pids.len(), pidfile_alive, orphan_pids.is_empty()) {
12565 (0, _, _) => DoctorCheck::fail(
12566 "daemon",
12567 "no `wire daemon` process running — nothing pulling inbox or pushing outbox",
12568 "`wire daemon &` to start, or re-run `wire up <handle>@<relay>` to bootstrap",
12569 ),
12570 (1, true, true) => DoctorCheck::pass(
12572 "daemon",
12573 format!(
12574 "one daemon running (pid {}, matches pidfile)",
12575 pgrep_pids[0]
12576 ),
12577 ),
12578 (n, true, false) => DoctorCheck::fail(
12580 "daemon",
12581 format!(
12582 "{n} `wire daemon` processes running (pids: {}); pidfile claims pid {} but pgrep also sees orphan(s): {}. \
12583 The orphans race the relay cursor — they advance past events your current binary can't process. \
12584 (Issue #2 exact class.)",
12585 fmt_pids(pgrep_pids),
12586 pidfile_pid.unwrap(),
12587 fmt_pids(orphan_pids),
12588 ),
12589 "`wire upgrade` kills all orphans and spawns a fresh daemon with a clean pidfile",
12590 ),
12591 (n, false, _) => DoctorCheck::fail(
12593 "daemon",
12594 format!(
12595 "{n} `wire daemon` process(es) running (pids: {}) but pidfile {} — \
12596 every running daemon is an orphan, advancing the cursor without coordinating with the current CLI. \
12597 (Issue #2 exact class: doctor previously PASSed this state while `wire status` said DOWN.)",
12598 fmt_pids(pgrep_pids),
12599 match pidfile_pid {
12600 Some(p) => format!("claims pid {p} which is dead"),
12601 None => "is missing".to_string(),
12602 },
12603 ),
12604 "`wire upgrade` to kill the orphan(s) and spawn a fresh daemon",
12605 ),
12606 (n, true, true) => DoctorCheck::warn(
12608 "daemon",
12609 format!(
12610 "{n} `wire daemon` processes running (pids: {}). Multiple daemons race the relay cursor.",
12611 fmt_pids(pgrep_pids)
12612 ),
12613 "kill all-but-one: `pkill -f \"wire daemon\"; wire daemon &`",
12614 ),
12615 }
12616}
12617
12618fn check_daemon_pid_consistency() -> DoctorCheck {
12630 let snap = crate::ensure_up::daemon_liveness();
12631 match &snap.record {
12632 crate::ensure_up::PidRecord::Missing => DoctorCheck::pass(
12633 "daemon_pid_consistency",
12634 "no daemon.pid yet — fresh box or daemon never started",
12635 ),
12636 crate::ensure_up::PidRecord::Corrupt(reason) => DoctorCheck::warn(
12637 "daemon_pid_consistency",
12638 format!("daemon.pid is corrupt: {reason}"),
12639 "delete state/wire/daemon.pid; next `wire daemon &` will rewrite",
12640 ),
12641 crate::ensure_up::PidRecord::LegacyInt(pid) => {
12642 let pid = *pid;
12645 if !crate::ensure_up::pid_is_alive(pid) {
12646 return DoctorCheck::warn(
12647 "daemon_pid_consistency",
12648 format!(
12649 "daemon.pid (legacy-int) points at pid {pid} which is not running. \
12650 Stale pidfile from a crashed pre-0.5.11 daemon. \
12651 (Issue #2: this surface used to PASS while `wire status` said DOWN.)"
12652 ),
12653 "`wire upgrade` (kills any orphan + spawns a fresh daemon with JSON pidfile)",
12654 );
12655 }
12656 DoctorCheck::warn(
12657 "daemon_pid_consistency",
12658 format!(
12659 "daemon.pid is legacy-int form (pid={pid}, no version/bin_path metadata). \
12660 Daemon was started by a pre-0.5.11 binary."
12661 ),
12662 "run `wire upgrade` to kill the old daemon and start a fresh one with the JSON pidfile",
12663 )
12664 }
12665 crate::ensure_up::PidRecord::Json(d) => {
12666 if !snap.pidfile_alive {
12670 return DoctorCheck::warn(
12671 "daemon_pid_consistency",
12672 format!(
12673 "daemon.pid records pid {pid} (v{version}) but that process is not running — \
12674 pidfile is stale. `wire status` will report DOWN, but pre-v0.5.19 doctor \
12675 silently PASSed this state and ignored any live orphan daemons (#2 root cause).",
12676 pid = d.pid,
12677 version = d.version,
12678 ),
12679 "`wire upgrade` to clean up the stale pidfile + spawn a fresh daemon \
12680 (kills any orphan daemon advancing the cursor without coordination)",
12681 );
12682 }
12683 let mut issues: Vec<String> = Vec::new();
12684 if d.schema != crate::ensure_up::DAEMON_PID_SCHEMA {
12685 issues.push(format!(
12686 "schema={} (expected {})",
12687 d.schema,
12688 crate::ensure_up::DAEMON_PID_SCHEMA
12689 ));
12690 }
12691 let cli_version = env!("CARGO_PKG_VERSION");
12692 if d.version != cli_version {
12693 issues.push(format!("version daemon={} cli={cli_version}", d.version));
12694 }
12695 if !std::path::Path::new(&d.bin_path).exists() {
12696 issues.push(format!("bin_path {} missing on disk", d.bin_path));
12697 }
12698 if let Ok(card) = config::read_agent_card()
12700 && let Some(current_did) = card.get("did").and_then(Value::as_str)
12701 && let Some(recorded_did) = &d.did
12702 && recorded_did != current_did
12703 {
12704 issues.push(format!(
12705 "did daemon={recorded_did} config={current_did} — identity drift"
12706 ));
12707 }
12708 if let Ok(state) = config::read_relay_state()
12709 && let Some(current_relay) = state
12710 .get("self")
12711 .and_then(|s| s.get("relay_url"))
12712 .and_then(Value::as_str)
12713 && let Some(recorded_relay) = &d.relay_url
12714 && recorded_relay != current_relay
12715 {
12716 issues.push(format!(
12717 "relay_url daemon={recorded_relay} config={current_relay} — relay-migration drift"
12718 ));
12719 }
12720 if issues.is_empty() {
12721 DoctorCheck::pass(
12722 "daemon_pid_consistency",
12723 format!(
12724 "daemon v{} bound to {} as {}",
12725 d.version,
12726 d.relay_url.as_deref().unwrap_or("?"),
12727 d.did.as_deref().unwrap_or("?")
12728 ),
12729 )
12730 } else {
12731 DoctorCheck::warn(
12732 "daemon_pid_consistency",
12733 format!("daemon pidfile drift: {}", issues.join("; ")),
12734 "`wire upgrade` to atomically restart daemon with current config".to_string(),
12735 )
12736 }
12737 }
12738 }
12739}
12740
12741fn check_relay_reachable() -> DoctorCheck {
12743 let state = match config::read_relay_state() {
12744 Ok(s) => s,
12745 Err(e) => {
12746 return DoctorCheck::fail(
12747 "relay",
12748 format!("could not read relay state: {e}"),
12749 "run `wire up <handle>@<relay>` to bootstrap",
12750 );
12751 }
12752 };
12753 let url = state
12754 .get("self")
12755 .and_then(|s| s.get("relay_url"))
12756 .and_then(Value::as_str)
12757 .unwrap_or("");
12758 if url.is_empty() {
12759 return DoctorCheck::warn(
12760 "relay",
12761 "no relay bound — wire send/pull will not work",
12762 "run `wire bind-relay <url>` or `wire up <handle>@<relay>`",
12763 );
12764 }
12765 let client = crate::relay_client::RelayClient::new(url);
12766 match client.check_healthz() {
12767 Ok(()) => DoctorCheck::pass("relay", format!("{url} healthz=200")),
12768 Err(e) => DoctorCheck::fail(
12769 "relay",
12770 format!("{url} unreachable: {e}"),
12771 format!("network reachable to {url}? relay running? check `curl {url}/healthz`"),
12772 ),
12773 }
12774}
12775
12776fn check_pair_rejections(recent_n: usize) -> DoctorCheck {
12780 let path = match config::state_dir() {
12781 Ok(d) => d.join("pair-rejected.jsonl"),
12782 Err(e) => {
12783 return DoctorCheck::warn(
12784 "pair_rejections",
12785 format!("could not resolve state dir: {e}"),
12786 "set WIRE_HOME or fix XDG_STATE_HOME",
12787 );
12788 }
12789 };
12790 if !path.exists() {
12791 return DoctorCheck::pass(
12792 "pair_rejections",
12793 "no pair-rejected.jsonl — no recorded pair failures",
12794 );
12795 }
12796 let body = match std::fs::read_to_string(&path) {
12797 Ok(b) => b,
12798 Err(e) => {
12799 return DoctorCheck::warn(
12800 "pair_rejections",
12801 format!("could not read {path:?}: {e}"),
12802 "check file permissions",
12803 );
12804 }
12805 };
12806 let lines: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
12807 if lines.is_empty() {
12808 return DoctorCheck::pass("pair_rejections", "pair-rejected.jsonl present but empty");
12809 }
12810 let total = lines.len();
12811 let recent: Vec<&str> = lines.iter().rev().take(recent_n).rev().copied().collect();
12812 let mut summary: Vec<String> = Vec::new();
12813 for line in &recent {
12814 if let Ok(rec) = serde_json::from_str::<Value>(line) {
12815 let peer = rec.get("peer").and_then(Value::as_str).unwrap_or("?");
12816 let code = rec.get("code").and_then(Value::as_str).unwrap_or("?");
12817 summary.push(format!("{peer}/{code}"));
12818 }
12819 }
12820 DoctorCheck::warn(
12821 "pair_rejections",
12822 format!(
12823 "{total} pair failures recorded. recent: [{}]",
12824 summary.join(", ")
12825 ),
12826 format!(
12827 "inspect {path:?} for full details. Each entry is a pair-flow error that previously silently dropped — re-run `wire pair <handle>@<relay>` to retry."
12828 ),
12829 )
12830}
12831
12832fn check_and_heal_self_userinfo_endpoints() -> DoctorCheck {
12896 let mut state = match config::read_relay_state() {
12897 Ok(s) => s,
12898 Err(_) => {
12899 return DoctorCheck::pass(
12900 "self-userinfo-endpoints",
12901 "no relay state yet — nothing published to heal".to_string(),
12902 );
12903 }
12904 };
12905 let self_block = match state.get_mut("self").and_then(Value::as_object_mut) {
12906 Some(s) => s,
12907 None => {
12908 return DoctorCheck::pass(
12909 "self-userinfo-endpoints",
12910 "no self block in relay state — nothing published to heal".to_string(),
12911 );
12912 }
12913 };
12914
12915 let mut stripped: Vec<String> = Vec::new();
12916 let mut clean_seed: Option<(String, String, String)> = None;
12917
12918 if let Some(endpoints) = self_block
12919 .get_mut("endpoints")
12920 .and_then(Value::as_array_mut)
12921 {
12922 endpoints.retain(|ep| {
12923 let url = ep.get("relay_url").and_then(Value::as_str).unwrap_or("");
12924 if assert_relay_url_clean_for_publish(url).is_err() {
12928 stripped.push(url.to_string());
12929 false
12930 } else {
12931 if clean_seed.is_none() {
12932 clean_seed = Some((
12933 url.to_string(),
12934 ep.get("slot_id")
12935 .and_then(Value::as_str)
12936 .unwrap_or("")
12937 .to_string(),
12938 ep.get("slot_token")
12939 .and_then(Value::as_str)
12940 .unwrap_or("")
12941 .to_string(),
12942 ));
12943 }
12944 true
12945 }
12946 });
12947 }
12948
12949 let mut legacy_healed = false;
12954 let legacy_url = self_block
12955 .get("relay_url")
12956 .and_then(Value::as_str)
12957 .unwrap_or("")
12958 .to_string();
12959 if !legacy_url.is_empty() && assert_relay_url_clean_for_publish(&legacy_url).is_err() {
12960 if let Some((url, sid, tok)) = &clean_seed {
12961 self_block.insert("relay_url".to_string(), Value::String(url.clone()));
12962 self_block.insert("slot_id".to_string(), Value::String(sid.clone()));
12963 self_block.insert("slot_token".to_string(), Value::String(tok.clone()));
12964 legacy_healed = true;
12965 stripped.push(format!("(legacy top-level) {legacy_url}"));
12966 } else {
12967 return DoctorCheck::warn(
12972 "self-userinfo-endpoints",
12973 format!(
12974 "your published endpoint is malformed (`{legacy_url}` — handle as URL \
12975 userinfo, the bug PR #61 prevents going forward) AND no clean endpoint \
12976 exists to fall back to. Inbound POSTs to this endpoint 4xx; bilateral \
12977 pairing can't complete."
12978 ),
12979 "Bind a clean federation slot first, then re-run doctor to heal: \
12980 `wire bind-relay https://wireup.net` (or your own relay). The bind \
12981 adds a clean endpoint additively; the next `wire doctor` run then \
12982 strips the malformed one safely. Finally re-publish your card with \
12983 `wire claim <your-persona>` so the phonebook serves the clean shape."
12984 .to_string(),
12985 );
12986 }
12987 }
12988
12989 if stripped.is_empty() && !legacy_healed {
12990 return DoctorCheck::pass(
12991 "self-userinfo-endpoints",
12992 "no malformed endpoints in self-state".to_string(),
12993 );
12994 }
12995
12996 if let Err(e) = config::write_relay_state(&state) {
13000 return DoctorCheck::warn(
13001 "self-userinfo-endpoints",
13002 format!(
13003 "detected {} malformed userinfo-bearing endpoint(s) in self-state but \
13004 failed to persist the heal: {e:#}. Found: {}",
13005 stripped.len(),
13006 stripped.join(", ")
13007 ),
13008 "re-run `wire doctor` — likely a transient lock contention".to_string(),
13009 );
13010 }
13011
13012 DoctorCheck::warn(
13013 "self-userinfo-endpoints",
13014 format!(
13015 "healed {} malformed endpoint(s) in self-state on disk: {}. \
13016 These were the `https://<handle>@<host>` shape that PR #61 prevents \
13017 at the write side but couldn't retroactively scrub from existing \
13018 operators. relay.json is now clean.",
13019 stripped.len(),
13020 stripped.join(", ")
13021 ),
13022 "re-publish your agent-card to the phonebook so peers resolve to the \
13023 clean endpoint: `wire claim <your-persona>` (find your persona with \
13024 `wire whoami`)."
13025 .to_string(),
13026 )
13027}
13028
13029fn check_peer_staleness(max_silent_days: u64) -> DoctorCheck {
13030 let state = match config::read_relay_state() {
13031 Ok(s) => s,
13032 Err(_) => {
13033 return DoctorCheck::pass(
13034 "peer-staleness",
13035 "no relay state yet — nothing pinned to check".to_string(),
13036 );
13037 }
13038 };
13039 let peers = match state.get("peers").and_then(Value::as_object) {
13040 Some(p) => p,
13041 None => {
13042 return DoctorCheck::pass("peer-staleness", "no pinned peers".to_string());
13043 }
13044 };
13045 if peers.is_empty() {
13046 return DoctorCheck::pass("peer-staleness", "no pinned peers".to_string());
13047 }
13048 let inbox_dir = match config::inbox_dir() {
13049 Ok(d) => d,
13050 Err(_) => {
13051 return DoctorCheck::warn(
13052 "peer-staleness",
13053 "could not resolve inbox dir; skipping peer-staleness check".to_string(),
13054 "check `wire status` for state-dir resolution".to_string(),
13055 );
13056 }
13057 };
13058 let threshold = std::time::Duration::from_secs(max_silent_days * 24 * 60 * 60);
13059 let now = std::time::SystemTime::now();
13060 let mut stale: Vec<(String, u64, &'static str)> = Vec::new();
13061 for (peer, _info) in peers {
13062 let path = inbox_dir.join(format!("{peer}.jsonl"));
13063 let (age_days, kind) = match std::fs::metadata(&path) {
13064 Ok(meta) => match meta
13065 .modified()
13066 .ok()
13067 .and_then(|m| now.duration_since(m).ok())
13068 {
13069 Some(d) if d > threshold => (d.as_secs() / (24 * 60 * 60), "silent"),
13070 Some(_) => continue, None => (0, "unknown-mtime"),
13072 },
13073 Err(_) => (max_silent_days + 1, "no-inbox-file"),
13074 };
13075 stale.push((peer.clone(), age_days, kind));
13076 }
13077 if stale.is_empty() {
13078 return DoctorCheck::pass(
13079 "peer-staleness",
13080 format!(
13081 "all {} pinned peer(s) have inbox traffic within the last {max_silent_days} day(s)",
13082 peers.len()
13083 ),
13084 );
13085 }
13086 let detail = stale
13087 .iter()
13088 .map(|(p, d, k)| match *k {
13089 "no-inbox-file" => format!("{p} (no inbox file)"),
13090 "unknown-mtime" => format!("{p} (unknown last-event time)"),
13091 _ => format!("{p} ({d}d silent)"),
13092 })
13093 .collect::<Vec<_>>()
13094 .join(", ");
13095 DoctorCheck::warn(
13096 "peer-staleness",
13097 format!(
13098 "{} pinned peer(s) silent for >{max_silent_days}d: {detail}. \
13099 If the peer re-bound their relay slot, our pin is now stale — \
13100 we push successfully to a dead slot and they never see us \
13101 (asymmetric failure, both sides report green).",
13102 stale.len()
13103 ),
13104 "re-pair with `wire add <peer>@<relay>` to refresh the slot. \
13105 Once issue #15 lands, this also auto-resolves on 410 Gone."
13106 .to_string(),
13107 )
13108}
13109
13110fn check_cursor_progress() -> DoctorCheck {
13111 let state = match config::read_relay_state() {
13112 Ok(s) => s,
13113 Err(e) => {
13114 return DoctorCheck::warn(
13115 "cursor",
13116 format!("could not read relay state: {e}"),
13117 "check ~/Library/Application Support/wire/relay.json",
13118 );
13119 }
13120 };
13121 let cursor = state
13122 .get("self")
13123 .and_then(|s| s.get("last_pulled_event_id"))
13124 .and_then(Value::as_str)
13125 .map(|s| s.chars().take(16).collect::<String>())
13126 .unwrap_or_else(|| "<none>".to_string());
13127 DoctorCheck::pass(
13128 "cursor",
13129 format!(
13130 "current cursor: {cursor}. P0.1 cursor blocking is active — see `wire pull --json` for cursor_blocked / rejected[].blocks_cursor entries."
13131 ),
13132 )
13133}
13134
13135#[cfg(test)]
13136mod doctor_tests {
13137 use super::*;
13138
13139 #[test]
13140 fn doctor_check_constructors_set_status_correctly() {
13141 let p = DoctorCheck::pass("x", "ok");
13146 assert_eq!(p.status, "PASS");
13147 assert_eq!(p.fix, None);
13148
13149 let w = DoctorCheck::warn("x", "watch out", "do this");
13150 assert_eq!(w.status, "WARN");
13151 assert_eq!(w.fix, Some("do this".to_string()));
13152
13153 let f = DoctorCheck::fail("x", "broken", "fix it");
13154 assert_eq!(f.status, "FAIL");
13155 assert_eq!(f.fix, Some("fix it".to_string()));
13156 }
13157
13158 #[test]
13159 fn check_pair_rejections_no_file_is_pass() {
13160 config::test_support::with_temp_home(|| {
13163 config::ensure_dirs().unwrap();
13164 let c = check_pair_rejections(5);
13165 assert_eq!(c.status, "PASS", "no file should be PASS, got {c:?}");
13166 });
13167 }
13168
13169 #[test]
13170 fn check_pair_rejections_with_entries_warns() {
13171 config::test_support::with_temp_home(|| {
13175 config::ensure_dirs().unwrap();
13176 crate::pair_invite::record_pair_rejection(
13177 "willard",
13178 "pair_drop_ack_send_failed",
13179 "POST 502",
13180 );
13181 let c = check_pair_rejections(5);
13182 assert_eq!(c.status, "WARN");
13183 assert!(c.detail.contains("1 pair failures"));
13184 assert!(c.detail.contains("willard/pair_drop_ack_send_failed"));
13185 });
13186 }
13187
13188 #[test]
13189 fn check_peer_staleness_no_peers_is_pass() {
13190 config::test_support::with_temp_home(|| {
13193 config::ensure_dirs().unwrap();
13194 let c = check_peer_staleness(7);
13195 assert_eq!(c.status, "PASS", "no peers should be PASS, got {c:?}");
13196 });
13197 }
13198
13199 #[test]
13200 fn check_peer_staleness_pinned_with_no_inbox_file_warns() {
13201 config::test_support::with_temp_home(|| {
13206 config::ensure_dirs().unwrap();
13207 let mut state = json!({
13209 "peers": {
13210 "stale-peer": {
13211 "relay_url": "https://wireup.net",
13212 "slot_id": "deadslot",
13213 "slot_token": "tok",
13214 }
13215 }
13216 });
13217 state["self"] = json!({});
13218 config::write_relay_state(&state).unwrap();
13219
13220 let c = check_peer_staleness(7);
13221 assert_eq!(
13222 c.status, "WARN",
13223 "pinned peer with no inbox file must surface: {c:?}"
13224 );
13225 assert!(
13226 c.detail.contains("stale-peer"),
13227 "WARN must name the silent peer so the operator can act: {}",
13228 c.detail
13229 );
13230 assert!(
13231 c.detail.contains("asymmetric")
13232 || c.detail.contains("stale")
13233 || c.detail.contains("dead slot"),
13234 "WARN must surface the failure-mode language so the operator \
13235 finds the diagnosis without re-tracing: {}",
13236 c.detail
13237 );
13238 assert!(
13239 c.fix
13240 .as_ref()
13241 .is_some_and(|f| f.contains("wire add") && f.contains("#15")),
13242 "fix pointer must reference both the manual re-pair AND the \
13243 follow-up issue (#15) that will automate this: {:?}",
13244 c.fix
13245 );
13246 });
13247 }
13248
13249 #[test]
13250 fn check_peer_staleness_pinned_with_fresh_inbox_is_pass() {
13251 config::test_support::with_temp_home(|| {
13255 config::ensure_dirs().unwrap();
13256 let mut state = json!({
13257 "peers": {
13258 "active-peer": {
13259 "relay_url": "https://wireup.net",
13260 "slot_id": "freshslot",
13261 "slot_token": "tok",
13262 }
13263 }
13264 });
13265 state["self"] = json!({});
13266 config::write_relay_state(&state).unwrap();
13267
13268 let inbox = config::inbox_dir().unwrap();
13269 std::fs::create_dir_all(&inbox).unwrap();
13270 std::fs::write(
13271 inbox.join("active-peer.jsonl"),
13272 "{\"event_id\":\"recent\"}\n",
13273 )
13274 .unwrap();
13275
13276 let c = check_peer_staleness(7);
13277 assert_eq!(c.status, "PASS", "fresh inbox should not warn: {c:?}");
13278 });
13279 }
13280
13281 #[test]
13282 fn check_self_userinfo_no_state_is_pass() {
13283 config::test_support::with_temp_home(|| {
13287 let c = check_and_heal_self_userinfo_endpoints();
13289 assert_eq!(c.status, "PASS", "no state should be PASS, got {c:?}");
13290 });
13291 }
13292
13293 #[test]
13294 fn check_self_userinfo_clean_state_is_pass_no_mutation() {
13295 config::test_support::with_temp_home(|| {
13299 config::ensure_dirs().unwrap();
13300 let state = json!({
13301 "self": {
13302 "endpoints": [
13303 {
13304 "relay_url": "https://wireup.net",
13305 "scope": "Federation",
13306 "slot_id": "abc",
13307 "slot_token": "tok"
13308 }
13309 ],
13310 "relay_url": "https://wireup.net",
13311 "slot_id": "abc",
13312 "slot_token": "tok"
13313 },
13314 "peers": {}
13315 });
13316 config::write_relay_state(&state).unwrap();
13317
13318 let c = check_and_heal_self_userinfo_endpoints();
13319 assert_eq!(c.status, "PASS", "clean state should be PASS: {c:?}");
13320
13321 let after = config::read_relay_state().unwrap();
13323 assert_eq!(after, state, "PASS path must NOT mutate relay.json");
13324 });
13325 }
13326
13327 #[test]
13328 fn check_self_userinfo_heals_malformed_endpoint_and_promotes_clean() {
13329 config::test_support::with_temp_home(|| {
13336 config::ensure_dirs().unwrap();
13337 let state = json!({
13338 "self": {
13339 "endpoints": [
13340 {
13341 "relay_url": "https://copilot-agent@wireup.net",
13342 "scope": "Federation",
13343 "slot_id": "stale-id",
13344 "slot_token": "stale-token"
13345 },
13346 {
13347 "relay_url": "https://wireup.net",
13348 "scope": "Federation",
13349 "slot_id": "clean-id",
13350 "slot_token": "clean-token"
13351 }
13352 ],
13353 "relay_url": "https://copilot-agent@wireup.net",
13354 "slot_id": "stale-id",
13355 "slot_token": "stale-token"
13356 },
13357 "peers": {}
13358 });
13359 config::write_relay_state(&state).unwrap();
13360
13361 let c = check_and_heal_self_userinfo_endpoints();
13362 assert_eq!(c.status, "WARN", "heal should report WARN: {c:?}");
13363 assert!(
13364 c.detail.contains("healed") && c.detail.contains("copilot-agent@wireup.net"),
13365 "WARN must name the stripped URL so the operator sees what changed: {}",
13366 c.detail
13367 );
13368 assert!(
13369 c.fix.as_ref().is_some_and(|f| f.contains("wire claim")),
13370 "fix must point at re-publishing the agent-card so the phonebook entry \
13371 matches the healed state on disk: {:?}",
13372 c.fix
13373 );
13374
13375 let after = config::read_relay_state().unwrap();
13379 let endpoints = after["self"]["endpoints"].as_array().unwrap();
13380 assert_eq!(endpoints.len(), 1, "malformed endpoint must be removed");
13381 assert_eq!(endpoints[0]["relay_url"], "https://wireup.net");
13382 assert_eq!(after["self"]["relay_url"], "https://wireup.net");
13383 assert_eq!(after["self"]["slot_id"], "clean-id");
13384 assert_eq!(after["self"]["slot_token"], "clean-token");
13385 });
13386 }
13387
13388 #[test]
13389 fn check_self_userinfo_no_clean_fallback_warns_without_mutating() {
13390 config::test_support::with_temp_home(|| {
13396 config::ensure_dirs().unwrap();
13397 let state = json!({
13398 "self": {
13399 "endpoints": [
13400 {
13401 "relay_url": "https://copilot-agent@wireup.net",
13402 "scope": "Federation",
13403 "slot_id": "stale-id",
13404 "slot_token": "stale-token"
13405 }
13406 ],
13407 "relay_url": "https://copilot-agent@wireup.net",
13408 "slot_id": "stale-id",
13409 "slot_token": "stale-token"
13410 },
13411 "peers": {}
13412 });
13413 config::write_relay_state(&state).unwrap();
13414
13415 let c = check_and_heal_self_userinfo_endpoints();
13416 assert_eq!(c.status, "WARN");
13417 assert!(
13418 c.fix
13419 .as_ref()
13420 .is_some_and(|f| f.contains("wire bind-relay") && f.contains("wire claim")),
13421 "no-clean-fallback fix must require BOTH a clean bind AND a re-claim: {:?}",
13422 c.fix
13423 );
13424
13425 let after = config::read_relay_state().unwrap();
13428 assert_eq!(
13429 after, state,
13430 "no-clean-fallback path must NOT mutate state (would strand operator)"
13431 );
13432 });
13433 }
13434}
13435
13436fn cmd_up(
13448 relay_arg: Option<&str>,
13449 name: Option<&str>,
13450 with_local: Option<&str>,
13451 no_local: bool,
13452 as_json: bool,
13453) -> Result<()> {
13454 let relay_url = match relay_arg {
13458 Some(r) => {
13459 let r = r.trim_start_matches('@');
13460 if r.starts_with("http://") || r.starts_with("https://") {
13461 r.to_string()
13462 } else {
13463 format!("https://{r}")
13464 }
13465 }
13466 None => crate::pair_invite::DEFAULT_RELAY.to_string(),
13467 };
13468
13469 let relay_url = strip_relay_url_userinfo(&relay_url);
13476
13477 let mut report: Vec<(String, String)> = Vec::new();
13478 let mut step = |stage: &str, detail: String| {
13479 report.push((stage.to_string(), detail.clone()));
13480 if !as_json {
13481 eprintln!("wire up: {stage} — {detail}");
13482 }
13483 };
13484
13485 if config::is_initialized()? {
13488 step("init", "already initialized".to_string());
13489 } else {
13490 cmd_init(
13491 None,
13492 name,
13493 Some(&relay_url),
13494 false,
13495 false,
13496 )?;
13497 step("init", format!("created identity bound to {relay_url}"));
13498 }
13499
13500 let canonical = {
13502 let card = config::read_agent_card()?;
13503 let did = card.get("did").and_then(Value::as_str).unwrap_or("");
13504 crate::agent_card::display_handle_from_did(did).to_string()
13505 };
13506 step("identity", format!("persona is `{canonical}`"));
13507
13508 let relay_state = config::read_relay_state()?;
13512 let bound_relay = relay_state
13513 .get("self")
13514 .and_then(|s| s.get("relay_url"))
13515 .and_then(Value::as_str)
13516 .unwrap_or("")
13517 .to_string();
13518 if bound_relay.is_empty() {
13519 cmd_bind_relay(
13523 &relay_url, None, false, false, false,
13525 )?;
13526 step("bind-relay", format!("bound to {relay_url}"));
13527 } else if bound_relay != relay_url {
13528 step(
13529 "bind-relay",
13530 format!(
13531 "WARNING: identity bound to {bound_relay} but you specified {relay_url}. \
13532 Keeping existing binding. Run `wire bind-relay {relay_url}` to switch."
13533 ),
13534 );
13535 } else {
13536 step("bind-relay", format!("already bound to {bound_relay}"));
13537 }
13538
13539 match cmd_claim(
13542 &canonical,
13543 Some(&relay_url),
13544 None,
13545 false,
13546 false,
13547 ) {
13548 Ok(()) => step(
13549 "claim",
13550 format!("{canonical}@{} claimed", strip_proto(&relay_url)),
13551 ),
13552 Err(e) => step(
13553 "claim",
13554 format!("WARNING: claim failed: {e}. You can retry `wire claim {canonical}`."),
13555 ),
13556 }
13557
13558 if no_local {
13563 step("local-slot", "skipped (--no-local)".to_string());
13564 } else {
13565 let local_url = with_local
13566 .unwrap_or("http://127.0.0.1:8771")
13567 .trim_end_matches('/');
13568 let already_local = crate::endpoints::self_endpoints(
13569 &config::read_relay_state().unwrap_or_else(|_| json!({})),
13570 )
13571 .iter()
13572 .any(|e| e.relay_url == local_url);
13573 if relay_url.trim_end_matches('/') == local_url || already_local {
13574 step("local-slot", "already covered".to_string());
13575 } else if crate::relay_client::RelayClient::new(local_url)
13576 .check_healthz()
13577 .is_ok()
13578 {
13579 match cmd_bind_relay(
13580 local_url,
13581 Some("local"),
13582 false,
13583 false,
13584 false,
13585 ) {
13586 Ok(()) => step(
13587 "local-slot",
13588 format!("dual-bound local relay {local_url} for sister routing"),
13589 ),
13590 Err(e) => step("local-slot", format!("skipped local relay: {e}")),
13591 }
13592 } else {
13593 step(
13594 "local-slot",
13595 format!(
13596 "no local relay reachable at {local_url} — federation only \
13597 (sisters resolve via session-list)"
13598 ),
13599 );
13600 }
13601 }
13602
13603 match crate::ensure_up::ensure_daemon_running() {
13605 Ok(true) => step("daemon", "started fresh background daemon".to_string()),
13606 Ok(false) => step("daemon", "already running".to_string()),
13607 Err(e) => step(
13608 "daemon",
13609 format!("WARNING: could not start daemon: {e}. Run `wire daemon &` manually."),
13610 ),
13611 }
13612
13613 let summary =
13615 "ready. `wire pair <peer>@<relay>` to pair, `wire send <peer> \"<msg>\"` to send, \
13616 `wire monitor` to watch incoming events."
13617 .to_string();
13618 step("ready", summary.clone());
13619
13620 if as_json {
13621 let steps_json: Vec<_> = report
13622 .iter()
13623 .map(|(k, v)| json!({"stage": k, "detail": v}))
13624 .collect();
13625 println!(
13626 "{}",
13627 serde_json::to_string(&json!({
13628 "nick": canonical,
13629 "relay": relay_url,
13630 "steps": steps_json,
13631 }))?
13632 );
13633 }
13634 Ok(())
13635}
13636
13637fn strip_proto(url: &str) -> String {
13639 url.trim_start_matches("https://")
13640 .trim_start_matches("http://")
13641 .to_string()
13642}
13643
13644fn error_smells_like_slot_4xx(last_err: &str) -> bool {
13721 fn is_token_boundary(b: u8) -> bool {
13722 matches!(b, b' ' | b':' | b'\t' | b'\n' | b'\r')
13723 }
13724 let bytes = last_err.as_bytes();
13725 for code in ["410", "404"] {
13726 let code_bytes = code.as_bytes();
13727 let mut search_from = 0usize;
13728 while let Some(rel) = last_err[search_from..].find(code) {
13729 let abs = search_from + rel;
13730 let end = abs + code_bytes.len();
13731 let before_ok = abs == 0 || is_token_boundary(bytes[abs - 1]);
13732 let after_ok = end == bytes.len() || is_token_boundary(bytes[end]);
13733 if before_ok && after_ok {
13734 return true;
13735 }
13736 search_from = abs + 1;
13740 }
13741 }
13742 false
13743}
13744
13745fn try_reresolve_peer_on_slot_4xx(
13780 state: &mut Value,
13781 peer_handle: &str,
13782 last_err: &str,
13783 already_tried: &std::collections::HashSet<String>,
13784) -> Result<bool> {
13785 if !error_smells_like_slot_4xx(last_err) {
13786 return Ok(false);
13788 }
13789 if already_tried.contains(peer_handle) {
13790 return Ok(false);
13792 }
13793 let peer_entry = state
13795 .get("peers")
13796 .and_then(|p| p.get(peer_handle))
13797 .ok_or_else(|| anyhow!("peer `{peer_handle}` not in relay_state"))?;
13798 let peer_relay = peer_entry
13799 .get("endpoints")
13800 .and_then(Value::as_array)
13801 .and_then(|arr| {
13802 arr.iter().find(|e| {
13803 e.get("scope").and_then(Value::as_str) == Some("federation")
13804 || e.get("scope").and_then(Value::as_str) == Some("Federation")
13805 })
13806 })
13807 .and_then(|e| e.get("relay_url").and_then(Value::as_str))
13808 .or_else(|| peer_entry.get("relay_url").and_then(Value::as_str))
13809 .ok_or_else(|| {
13810 anyhow!("peer `{peer_handle}` has no federation endpoint to re-resolve against")
13811 })?
13812 .to_string();
13813 let domain = peer_relay
13816 .trim_start_matches("https://")
13817 .trim_start_matches("http://")
13818 .split('/')
13819 .next()
13820 .unwrap_or(&peer_relay)
13821 .to_string();
13822 let handle = crate::pair_profile::Handle {
13823 nick: peer_handle.to_string(),
13824 domain,
13825 };
13826 let resolved = crate::pair_profile::resolve_handle(&handle, Some(&peer_relay))?;
13827 let new_slot_id = resolved
13828 .get("slot_id")
13829 .and_then(Value::as_str)
13830 .ok_or_else(|| anyhow!("re-resolved payload missing slot_id"))?
13831 .to_string();
13832 let peers = state
13834 .get_mut("peers")
13835 .and_then(Value::as_object_mut)
13836 .ok_or_else(|| anyhow!("relay_state.peers missing or wrong shape"))?;
13837 let peer_entry = peers
13838 .get_mut(peer_handle)
13839 .ok_or_else(|| anyhow!("peer `{peer_handle}` disappeared from state mid-resolve"))?;
13840 let current_slot_id = peer_entry
13841 .get("endpoints")
13842 .and_then(Value::as_array)
13843 .and_then(|arr| {
13844 arr.iter().find(|e| {
13845 let scope = e.get("scope").and_then(Value::as_str);
13846 scope == Some("federation") || scope == Some("Federation")
13847 })
13848 })
13849 .and_then(|e| e.get("slot_id").and_then(Value::as_str))
13850 .unwrap_or("")
13851 .to_string();
13852 if current_slot_id == new_slot_id {
13853 return Ok(false);
13855 }
13856 if let Some(endpoints) = peer_entry
13865 .get_mut("endpoints")
13866 .and_then(Value::as_array_mut)
13867 {
13868 for ep in endpoints.iter_mut() {
13869 let scope = ep.get("scope").and_then(Value::as_str);
13870 if scope == Some("federation") || scope == Some("Federation") {
13871 ep["slot_id"] = Value::String(new_slot_id.clone());
13872 ep["slot_token"] = Value::String(String::new());
13873 }
13874 }
13875 }
13876 peer_entry["slot_id"] = Value::String(new_slot_id.clone());
13879 peer_entry["slot_token"] = Value::String(String::new());
13880 eprintln!(
13881 "wire push: peer `{peer_handle}` rotated their relay slot (was `{current_slot_id}`, \
13882 now `{new_slot_id}`); pin updated in place. Re-pair via `wire add \
13883 {peer_handle}@<relay>` to refresh the slot_token."
13884 );
13885 Ok(true)
13886}
13887
13888fn reject_self_pair_after_resolution(our_did: &str, peer_did: &str) -> Result<()> {
13889 if our_did == peer_did {
13890 bail!(
13891 "refusing to self-pair: resolved peer DID `{peer_did}` matches your own \
13892 DID. Two terminals can collapse onto one wire identity when the per-\
13893 session key isn't reaching the wire process (issue #30 / #29).\n\n\
13894 Diagnose:\n \
13895 • `wire whoami` in each terminal — DIDs MUST differ.\n \
13896 • `echo $WIRE_SESSION_ID` (bash) / `echo $env:WIRE_SESSION_ID` \
13897 (PowerShell) — must be set + distinct per session.\n\n\
13898 Force distinct identities before relaunching the agent:\n \
13899 • bash/zsh: `export WIRE_SESSION_ID=\"$(uuidgen)\"`\n \
13900 • PowerShell: `$env:WIRE_SESSION_ID = [guid]::NewGuid().ToString()`"
13901 );
13902 }
13903 Ok(())
13904}
13905
13906fn strip_relay_url_userinfo(url: &str) -> String {
13907 let authority_start = url.find("://").map(|i| i + 3).unwrap_or(0);
13910 let rest = &url[authority_start..];
13911 let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
13912 let authority = &rest[..authority_end];
13913
13914 let Some(at_pos) = authority.find('@') else {
13915 return url.to_string();
13916 };
13917
13918 let userinfo = &authority[..at_pos];
13919 let host = &authority[at_pos + 1..];
13920 let scheme = &url[..authority_start];
13921 let tail = &rest[authority_end..];
13922 let cleaned = format!("{scheme}{host}{tail}");
13923
13924 eprintln!(
13925 "wire: ignoring `{userinfo}@` prefix on relay URL `{url}` — \
13926 in v0.11+ your handle is DID-derived (one-name rule), so the relay URL \
13927 is just the bare relay. Binding to `{cleaned}` instead."
13928 );
13929
13930 cleaned
13931}
13932
13933fn assert_relay_url_clean_for_publish(url: &str) -> Result<()> {
13941 let authority_start = url.find("://").map(|i| i + 3).unwrap_or(0);
13942 let rest = &url[authority_start..];
13943 let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
13944 let authority = &rest[..authority_end];
13945 if authority.contains('@') {
13946 bail!(
13947 "internal invariant violated: relay URL `{url}` still carries userinfo at \
13948 the persist/publish boundary — `strip_relay_url_userinfo` must be called \
13949 before this point. Refusing to publish a malformed endpoint."
13950 );
13951 }
13952 Ok(())
13953}
13954
13955fn cmd_pair_megacommand(
13969 handle_arg: &str,
13970 relay_override: Option<&str>,
13971 timeout_secs: u64,
13972 _as_json: bool,
13973) -> Result<()> {
13974 let parsed = crate::pair_profile::parse_handle(handle_arg)?;
13975 let peer_handle = parsed.nick.clone();
13976
13977 eprintln!("wire pair: resolving {handle_arg}...");
13978 cmd_add(
13979 handle_arg,
13980 relay_override,
13981 false,
13982 false,
13983 )?;
13984
13985 eprintln!(
13986 "wire pair: intro delivered. waiting up to {timeout_secs}s for {peer_handle} \
13987 to ack (their daemon must be running + pulling)..."
13988 );
13989
13990 let _ = run_sync_pull();
13994
13995 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
13996 let poll_interval = std::time::Duration::from_millis(500);
13997
13998 loop {
13999 let _ = run_sync_pull();
14001 let relay_state = config::read_relay_state()?;
14002 let peer_entry = relay_state
14003 .get("peers")
14004 .and_then(|p| p.get(&peer_handle))
14005 .cloned();
14006 let token = peer_entry
14007 .as_ref()
14008 .and_then(|e| e.get("slot_token"))
14009 .and_then(Value::as_str)
14010 .unwrap_or("");
14011
14012 if !token.is_empty() {
14013 let trust = config::read_trust()?;
14015 let pinned_in_trust = trust
14016 .get("agents")
14017 .and_then(|a| a.get(&peer_handle))
14018 .is_some();
14019 println!(
14020 "wire pair: paired with {peer_handle}.\n trust: {} bilateral: yes (slot_token recorded)\n next: `wire send {peer_handle} \"<msg>\"`",
14021 if pinned_in_trust {
14022 "VERIFIED"
14023 } else {
14024 "MISSING (bug)"
14025 }
14026 );
14027 return Ok(());
14028 }
14029
14030 if std::time::Instant::now() >= deadline {
14031 bail!(
14038 "wire pair: timed out after {timeout_secs}s. \
14039 peer {peer_handle} never sent pair_drop_ack. \
14040 likely causes: (a) their daemon is down — ask them to run \
14041 `wire status` and `wire daemon &`; (b) their binary is older \
14042 than 0.5.x and doesn't understand pair_drop events — ask \
14043 them to `wire upgrade`; (c) network / relay blip — re-run \
14044 `wire pair {handle_arg}` to retry."
14045 );
14046 }
14047
14048 std::thread::sleep(poll_interval);
14049 }
14050}
14051
14052fn cmd_claim(
14053 nick: &str,
14054 relay_override: Option<&str>,
14055 public_url: Option<&str>,
14056 hidden: bool,
14057 as_json: bool,
14058) -> Result<()> {
14059 let (_did, relay_url, slot_id, slot_token) =
14062 crate::pair_invite::ensure_self_with_relay(relay_override)?;
14063 let card = config::read_agent_card()?;
14064
14065 let did = card.get("did").and_then(Value::as_str).unwrap_or_default();
14074 let canonical = crate::agent_card::display_handle_from_did(did).to_string();
14075 if !canonical.is_empty() && nick != canonical && !as_json {
14076 eprintln!(
14077 "wire claim: typed `{nick}` ignored — one-name rule. Claiming your persona `{canonical}`."
14078 );
14079 }
14080 let nick = if canonical.is_empty() {
14081 nick
14082 } else {
14083 canonical.as_str()
14084 };
14085 if !crate::pair_profile::is_valid_nick(nick) {
14086 bail!(
14087 "phyllis: {nick:?} won't fit in the books — handles need 2-32 chars, lowercase [a-z0-9_-], not on the reserved list"
14088 );
14089 }
14090
14091 let client = crate::relay_client::RelayClient::new(&relay_url);
14092 let discoverable = if hidden { Some(false) } else { None };
14096 let resp =
14097 client.handle_claim_v2(nick, &slot_id, &slot_token, public_url, &card, discoverable)?;
14098
14099 if as_json {
14100 println!(
14101 "{}",
14102 serde_json::to_string(&json!({
14103 "nick": nick,
14104 "relay": relay_url,
14105 "response": resp,
14106 }))?
14107 );
14108 } else {
14109 let domain = public_url
14113 .unwrap_or(&relay_url)
14114 .trim_start_matches("https://")
14115 .trim_start_matches("http://")
14116 .trim_end_matches('/')
14117 .split('/')
14118 .next()
14119 .unwrap_or("<this-relay-domain>")
14120 .to_string();
14121 println!("claimed {nick} on {relay_url} — others can reach you at: {nick}@{domain}");
14122 println!("verify with: wire whois {nick}@{domain}");
14123 }
14124 Ok(())
14125}
14126
14127fn cmd_profile(action: ProfileAction) -> Result<()> {
14128 match action {
14129 ProfileAction::Set { field, value, json } => {
14130 let parsed: Value =
14134 serde_json::from_str(&value).unwrap_or(Value::String(value.clone()));
14135 let new_profile = crate::pair_profile::write_profile_field(&field, parsed)?;
14136 let published = republish_card_to_phonebook();
14137 if json {
14138 println!(
14139 "{}",
14140 serde_json::to_string(&json!({
14141 "field": field,
14142 "profile": new_profile,
14143 "published_to": published,
14144 }))?
14145 );
14146 } else {
14147 println!("profile.{field} set");
14148 print_profile_publish_result(&published);
14149 }
14150 }
14151 ProfileAction::Get { json } => return cmd_whois(None, json, None),
14152 ProfileAction::Clear { field, json } => {
14153 let new_profile = crate::pair_profile::write_profile_field(&field, Value::Null)?;
14154 let published = republish_card_to_phonebook();
14155 if json {
14156 println!(
14157 "{}",
14158 serde_json::to_string(&json!({
14159 "field": field,
14160 "cleared": true,
14161 "profile": new_profile,
14162 "published_to": published,
14163 }))?
14164 );
14165 } else {
14166 println!("profile.{field} cleared");
14167 print_profile_publish_result(&published);
14168 }
14169 }
14170 }
14171 Ok(())
14172}
14173
14174fn republish_card_to_phonebook() -> Vec<String> {
14182 let Ok(card) = config::read_agent_card() else {
14183 return Vec::new();
14184 };
14185 let did = card.get("did").and_then(Value::as_str).unwrap_or_default();
14186 let persona = crate::agent_card::display_handle_from_did(did).to_string();
14187 if persona.is_empty() {
14188 return Vec::new();
14189 }
14190 let Ok(state) = config::read_relay_state() else {
14191 return Vec::new();
14192 };
14193 let mut published = Vec::new();
14194 for ep in crate::endpoints::self_endpoints(&state) {
14195 if ep.scope != crate::endpoints::EndpointScope::Federation
14196 || ep.slot_id.is_empty()
14197 || ep.slot_token.is_empty()
14198 {
14199 continue;
14200 }
14201 let client = crate::relay_client::RelayClient::new(&ep.relay_url);
14202 if client
14203 .handle_claim_v2(&persona, &ep.slot_id, &ep.slot_token, None, &card, None)
14204 .is_ok()
14205 {
14206 published.push(ep.relay_url.clone());
14207 }
14208 }
14209 published
14210}
14211
14212fn print_profile_publish_result(published: &[String]) {
14213 if published.is_empty() {
14214 println!(
14215 " (local only — not bound to a federation relay; run `wire up` to publish to the phonebook)"
14216 );
14217 } else {
14218 println!(" published to phonebook: {}", published.join(", "));
14219 }
14220}
14221
14222fn cmd_setup(apply: bool) -> Result<()> {
14225 use std::path::PathBuf;
14226
14227 let entry = json!({
14243 "command": "wire",
14244 "args": ["mcp"]
14245 });
14246 let entry_pretty = serde_json::to_string_pretty(&json!({"wire": &entry}))?;
14247
14248 let mut targets: Vec<(&str, PathBuf)> = Vec::new();
14251 if let Some(home) = dirs::home_dir() {
14252 targets.push(("Claude Code", home.join(".claude.json")));
14255 targets.push(("Claude Code (alt)", home.join(".config/claude/mcp.json")));
14257 #[cfg(target_os = "macos")]
14259 targets.push((
14260 "Claude Desktop (macOS)",
14261 home.join("Library/Application Support/Claude/claude_desktop_config.json"),
14262 ));
14263 #[cfg(target_os = "windows")]
14265 if let Ok(appdata) = std::env::var("APPDATA") {
14266 targets.push((
14267 "Claude Desktop (Windows)",
14268 PathBuf::from(appdata).join("Claude/claude_desktop_config.json"),
14269 ));
14270 }
14271 targets.push(("Cursor", home.join(".cursor/mcp.json")));
14273
14274 #[cfg(target_os = "macos")]
14276 targets.push((
14277 "VS Code (GitHub Copilot)",
14278 home.join("Library/Application Support/Code/User/settings.json"),
14279 ));
14280 #[cfg(target_os = "linux")]
14281 targets.push((
14282 "VS Code (GitHub Copilot)",
14283 home.join(".config/Code/User/settings.json"),
14284 ));
14285 #[cfg(target_os = "windows")]
14286 if let Ok(appdata) = std::env::var("APPDATA") {
14287 targets.push((
14288 "VS Code (GitHub Copilot)",
14289 PathBuf::from(appdata).join("Code/User/settings.json"),
14290 ));
14291 }
14292
14293 #[cfg(target_os = "macos")]
14295 targets.push((
14296 "VS Code Insiders",
14297 home.join("Library/Application Support/Code - Insiders/User/settings.json"),
14298 ));
14299 #[cfg(target_os = "linux")]
14300 targets.push((
14301 "VS Code Insiders",
14302 home.join(".config/Code - Insiders/User/settings.json"),
14303 ));
14304 #[cfg(target_os = "windows")]
14305 if let Ok(appdata) = std::env::var("APPDATA") {
14306 targets.push((
14307 "VS Code Insiders",
14308 PathBuf::from(appdata).join("Code - Insiders/User/settings.json"),
14309 ));
14310 }
14311
14312 if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
14317 targets.push((
14318 "GitHub Copilot CLI (XDG)",
14319 PathBuf::from(xdg).join("copilot/mcp-config.json"),
14320 ));
14321 }
14322 targets.push(("GitHub Copilot CLI", home.join(".copilot/mcp-config.json")));
14323 }
14324 targets.push((
14326 "VS Code (workspace)",
14327 PathBuf::from(".vscode/settings.json"),
14328 ));
14329 targets.push(("project-local (.mcp.json)", PathBuf::from(".mcp.json")));
14331
14332 println!("wire setup\n");
14333 println!("MCP server snippet (add this to your client's mcpServers):");
14334 println!();
14335 println!("{entry_pretty}");
14336 println!();
14337
14338 if !apply {
14339 println!("Probable MCP host config locations on this machine:");
14340 for (name, path) in &targets {
14341 let marker = if path.exists() {
14342 "✓ found"
14343 } else {
14344 " (would create)"
14345 };
14346 println!(" {marker:14} {name}: {}", path.display());
14347 }
14348 println!();
14349 println!("Run `wire setup --apply` to merge wire into each config above.");
14350 println!(
14351 "Existing entries with a different command keep yours unchanged unless wire's exact entry is missing."
14352 );
14353 return Ok(());
14354 }
14355
14356 let mut modified: Vec<String> = Vec::new();
14357 let mut skipped: Vec<String> = Vec::new();
14358 for (name, path) in &targets {
14359 match upsert_mcp_entry(path, "wire", &entry) {
14360 Ok(true) => modified.push(format!("✓ {name} ({})", path.display())),
14361 Ok(false) => skipped.push(format!(" {name} ({}): already configured", path.display())),
14362 Err(e) => skipped.push(format!("✗ {name} ({}): {e}", path.display())),
14363 }
14364 }
14365 if !modified.is_empty() {
14366 println!("Modified:");
14367 for line in &modified {
14368 println!(" {line}");
14369 }
14370 println!();
14371 println!("Restart the app(s) above to load wire MCP.");
14372 }
14373 if !skipped.is_empty() {
14374 println!();
14375 println!("Skipped:");
14376 for line in &skipped {
14377 println!(" {line}");
14378 }
14379 }
14380 Ok(())
14381}
14382
14383fn upsert_mcp_entry(path: &std::path::Path, server_name: &str, entry: &Value) -> Result<bool> {
14390 let mut cfg: Value = if path.exists() {
14391 let body = std::fs::read_to_string(path).context("reading config")?;
14392 if body.trim().is_empty() {
14393 json!({})
14394 } else {
14395 serde_json::from_str(&body).with_context(|| {
14401 format!(
14402 "{} is not strict JSON (comments / trailing commas?); \
14403 add the wire MCP entry manually to avoid overwriting it",
14404 path.display()
14405 )
14406 })?
14407 }
14408 } else {
14409 json!({})
14410 };
14411 if !cfg.is_object() {
14412 cfg = json!({});
14413 }
14414
14415 let is_vscode = path.to_string_lossy().contains("Code/User/settings.json")
14417 || path.to_string_lossy().contains(".vscode/settings.json")
14418 || path.to_string_lossy().contains("Code - Insiders");
14419
14420 let root = cfg.as_object_mut().unwrap();
14421
14422 if is_vscode {
14423 let mcp = root.entry("mcp".to_string()).or_insert_with(|| json!({}));
14425 if !mcp.is_object() {
14426 *mcp = json!({});
14427 }
14428 let mcp_obj = mcp.as_object_mut().unwrap();
14429 let servers = mcp_obj
14430 .entry("servers".to_string())
14431 .or_insert_with(|| json!({}));
14432 if !servers.is_object() {
14433 *servers = json!({});
14434 }
14435 let map = servers.as_object_mut().unwrap();
14436 if map.get(server_name) == Some(entry) {
14437 return Ok(false);
14438 }
14439 map.insert(server_name.to_string(), entry.clone());
14440 } else {
14441 let servers = root
14443 .entry("mcpServers".to_string())
14444 .or_insert_with(|| json!({}));
14445 if !servers.is_object() {
14446 *servers = json!({});
14447 }
14448 let map = servers.as_object_mut().unwrap();
14449 if map.get(server_name) == Some(entry) {
14450 return Ok(false);
14451 }
14452 map.insert(server_name.to_string(), entry.clone());
14453 }
14454
14455 if let Some(parent) = path.parent()
14456 && !parent.as_os_str().is_empty()
14457 {
14458 std::fs::create_dir_all(parent).context("creating parent dir")?;
14459 }
14460 let out = serde_json::to_string_pretty(&cfg)? + "\n";
14461 std::fs::write(path, out).context("writing config")?;
14462 Ok(true)
14463}
14464
14465const STATUSLINE_RENDERER: &str = include_str!("../assets/wire-statusline.sh");
14471
14472fn cmd_setup_statusline(apply: bool, remove: bool) -> Result<()> {
14478 use std::path::PathBuf;
14479 let cfg_dir: PathBuf = std::env::var_os("CLAUDE_CONFIG_DIR")
14480 .map(PathBuf::from)
14481 .or_else(|| dirs::home_dir().map(|h| h.join(".claude")))
14482 .ok_or_else(|| anyhow!("cannot locate Claude config dir (set $CLAUDE_CONFIG_DIR)"))?;
14483 let settings_path = cfg_dir.join("settings.json");
14484 let script_path = cfg_dir.join("wire-statusline.sh");
14485 let (command, command_warn) = statusline_command(&script_path);
14490
14491 println!("wire setup --statusline\n");
14492 println!("Claude config dir: {}", cfg_dir.display());
14493 println!(" renderer: {}", script_path.display());
14494 println!(" settings: {}", settings_path.display());
14495 if let Some(w) = &command_warn {
14496 println!(" ⚠ {w}");
14497 }
14498 println!();
14499
14500 if remove {
14501 if !apply {
14502 println!("Would REMOVE the statusLine key from settings.json and delete the renderer.");
14503 println!("Run `wire setup --statusline --remove --apply` to do it.");
14504 return Ok(());
14505 }
14506 let dropped = remove_statusline_entry(&settings_path)?;
14507 let script_gone = if script_path.exists() {
14508 std::fs::remove_file(&script_path).is_ok()
14509 } else {
14510 false
14511 };
14512 println!(
14513 "Removed: statusLine key {} · renderer {}",
14514 if dropped { "dropped" } else { "absent" },
14515 if script_gone { "deleted" } else { "absent" }
14516 );
14517 return Ok(());
14518 }
14519
14520 if !apply {
14521 println!("Would write the renderer above and merge into settings.json:");
14522 println!();
14523 println!(" \"statusLine\": {{ \"type\": \"command\", \"command\": \"{command}\" }}");
14524 println!();
14525 println!("Resulting statusline: ● <emoji> <nickname> · <cwd>");
14526 println!("Run `wire setup --statusline --apply` to install.");
14527 println!("(Existing settings.json keys are preserved; an invalid settings.json aborts.)");
14528 return Ok(());
14529 }
14530
14531 if let Some(parent) = script_path.parent() {
14532 std::fs::create_dir_all(parent).context("creating Claude config dir")?;
14533 }
14534 std::fs::write(&script_path, STATUSLINE_RENDERER).context("writing renderer script")?;
14535 #[cfg(unix)]
14536 {
14537 use std::os::unix::fs::PermissionsExt;
14538 if let Ok(meta) = std::fs::metadata(&script_path) {
14539 let mut perms = meta.permissions();
14540 perms.set_mode(0o755);
14541 let _ = std::fs::set_permissions(&script_path, perms);
14542 }
14543 }
14544 let changed = upsert_statusline_entry(&settings_path, &command)?;
14545 println!("✓ renderer written: {}", script_path.display());
14546 if changed {
14547 println!("✓ merged statusLine into: {}", settings_path.display());
14548 } else {
14549 println!(
14550 " settings.json already configured: {}",
14551 settings_path.display()
14552 );
14553 }
14554 println!();
14555 println!("Restart Claude Code (or reopen the session) to see your persona in the statusline.");
14556 Ok(())
14557}
14558
14559fn upsert_statusline_entry(path: &std::path::Path, command: &str) -> Result<bool> {
14563 let mut cfg: Value = if path.exists() {
14564 let body = std::fs::read_to_string(path).context("reading settings.json")?;
14565 if body.trim().is_empty() {
14566 json!({})
14567 } else {
14568 serde_json::from_str(&body).context(
14569 "settings.json exists but is not valid JSON — refusing to clobber; fix or remove it first",
14570 )?
14571 }
14572 } else {
14573 json!({})
14574 };
14575 if !cfg.is_object() {
14576 bail!("settings.json root is not a JSON object — refusing to clobber");
14577 }
14578 let desired = json!({"type": "command", "command": command});
14579 let root = cfg.as_object_mut().unwrap();
14580 if root.get("statusLine") == Some(&desired) {
14581 return Ok(false);
14582 }
14583 root.insert("statusLine".to_string(), desired);
14584 if let Some(parent) = path.parent()
14585 && !parent.as_os_str().is_empty()
14586 {
14587 std::fs::create_dir_all(parent).context("creating parent dir")?;
14588 }
14589 let out = serde_json::to_string_pretty(&cfg)? + "\n";
14590 std::fs::write(path, out).context("writing settings.json")?;
14591 Ok(true)
14592}
14593
14594fn remove_statusline_entry(path: &std::path::Path) -> Result<bool> {
14597 if !path.exists() {
14598 return Ok(false);
14599 }
14600 let body = std::fs::read_to_string(path).context("reading settings.json")?;
14601 if body.trim().is_empty() {
14602 return Ok(false);
14603 }
14604 let mut cfg: Value = serde_json::from_str(&body)
14605 .context("settings.json is not valid JSON — refusing to edit")?;
14606 let Some(root) = cfg.as_object_mut() else {
14607 return Ok(false);
14608 };
14609 if root.remove("statusLine").is_none() {
14610 return Ok(false);
14611 }
14612 let out = serde_json::to_string_pretty(&cfg)? + "\n";
14613 std::fs::write(path, out).context("writing settings.json")?;
14614 Ok(true)
14615}
14616
14617fn statusline_command(script_path: &std::path::Path) -> (String, Option<String>) {
14620 #[cfg(windows)]
14621 {
14622 match resolve_git_bash() {
14623 Some(bash) => (format!("\"{}\" \"{}\"", bash, script_path.display()), None),
14624 None => (
14625 format!("bash \"{}\"", script_path.display()),
14626 Some(
14627 "could not locate git-bash; using bare `bash`. On Windows that may resolve to \
14628 WSL (System32\\bash.exe) and the statusline will be blank — install Git for \
14629 Windows or set statusLine.command to your git-bash bash.exe path."
14630 .to_string(),
14631 ),
14632 ),
14633 }
14634 }
14635 #[cfg(unix)]
14636 {
14637 (format!("bash \"{}\"", script_path.display()), None)
14638 }
14639}
14640
14641#[cfg(windows)]
14645fn resolve_git_bash() -> Option<String> {
14646 use std::path::PathBuf;
14647 if let Ok(out) = std::process::Command::new("where.exe").arg("bash").output()
14650 && out.status.success()
14651 {
14652 for line in String::from_utf8_lossy(&out.stdout).lines() {
14653 let p = line.trim();
14654 if !p.is_empty() && !p.to_lowercase().contains("\\system32\\") {
14655 return Some(p.to_string());
14656 }
14657 }
14658 }
14659 let candidates = [
14661 std::env::var("ProgramFiles")
14662 .ok()
14663 .map(|p| format!("{p}\\Git\\bin\\bash.exe")),
14664 std::env::var("ProgramFiles(x86)")
14665 .ok()
14666 .map(|p| format!("{p}\\Git\\bin\\bash.exe")),
14667 std::env::var("LocalAppData")
14668 .ok()
14669 .map(|p| format!("{p}\\Programs\\Git\\bin\\bash.exe")),
14670 ];
14671 candidates
14672 .into_iter()
14673 .flatten()
14674 .find(|c| PathBuf::from(c).exists())
14675}
14676
14677#[cfg(test)]
14678mod statusline_tests {
14679 use super::*;
14680
14681 #[test]
14682 fn statusline_merge_preserves_keys_and_is_idempotent() {
14683 let dir = tempfile::tempdir().unwrap();
14684 let path = dir.path().join("settings.json");
14685 std::fs::write(&path, r#"{"theme":"dark","model":"opus"}"#).unwrap();
14686 assert!(upsert_statusline_entry(&path, "bash /x.sh").unwrap());
14688 let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
14689 assert_eq!(v["theme"], "dark");
14690 assert_eq!(v["model"], "opus");
14691 assert_eq!(v["statusLine"]["type"], "command");
14692 assert_eq!(v["statusLine"]["command"], "bash /x.sh");
14693 assert!(!upsert_statusline_entry(&path, "bash /x.sh").unwrap());
14695 assert!(remove_statusline_entry(&path).unwrap());
14697 let v2: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
14698 assert_eq!(v2["theme"], "dark");
14699 assert!(v2.get("statusLine").is_none());
14700 assert!(!remove_statusline_entry(&path).unwrap());
14702 }
14703
14704 #[test]
14705 fn statusline_merge_refuses_to_clobber_invalid_json() {
14706 let dir = tempfile::tempdir().unwrap();
14707 let path = dir.path().join("settings.json");
14708 std::fs::write(&path, "this is not json {").unwrap();
14709 let err = upsert_statusline_entry(&path, "bash /x.sh").unwrap_err();
14710 assert!(
14711 format!("{err:#}").contains("not valid JSON"),
14712 "err: {err:#}"
14713 );
14714 assert_eq!(
14716 std::fs::read_to_string(&path).unwrap(),
14717 "this is not json {"
14718 );
14719 }
14720
14721 #[test]
14722 fn statusline_creates_settings_when_absent() {
14723 let dir = tempfile::tempdir().unwrap();
14724 let path = dir.path().join("settings.json");
14725 assert!(upsert_statusline_entry(&path, "bash /x.sh").unwrap());
14726 let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
14727 assert_eq!(v["statusLine"]["command"], "bash /x.sh");
14728 }
14729}
14730
14731fn cmd_notify(
14734 interval_secs: u64,
14735 peer_filter: Option<&str>,
14736 once: bool,
14737 as_json: bool,
14738) -> Result<()> {
14739 use crate::inbox_watch::InboxWatcher;
14740 let cursor_path = config::state_dir()?.join("notify.cursor");
14741 let mut watcher = InboxWatcher::from_cursor_file(&cursor_path)?;
14742 if !once {
14746 crate::session::warn_on_identity_collision(std::process::id(), "notify");
14747 }
14748
14749 let sweep = |watcher: &mut InboxWatcher| -> Result<()> {
14750 let events = watcher.poll()?;
14751 for ev in events {
14752 if let Some(p) = peer_filter
14753 && ev.peer != p
14754 {
14755 continue;
14756 }
14757 if as_json {
14758 println!("{}", serde_json::to_string(&ev)?);
14759 } else {
14760 os_notify_inbox_event(&ev);
14761 }
14762 }
14763 watcher.save_cursors(&cursor_path)?;
14764 Ok(())
14765 };
14766
14767 if once {
14768 return sweep(&mut watcher);
14769 }
14770
14771 let interval = std::time::Duration::from_secs(interval_secs.max(1));
14772 loop {
14773 if let Err(e) = sweep(&mut watcher) {
14774 eprintln!("wire notify: sweep error: {e}");
14775 }
14776 std::thread::sleep(interval);
14777 }
14778}
14779
14780fn os_notify_inbox_event(ev: &crate::inbox_watch::InboxEvent) {
14781 let who = persona_label(&ev.peer);
14782 let title = if ev.verified {
14783 format!("wire ← {who}")
14784 } else {
14785 format!("wire ← {who} (UNVERIFIED)")
14786 };
14787 let body = format!("{}: {}", ev.kind, ev.body_preview);
14788 let id = if ev.event_id.is_empty() {
14794 ev.body_preview.as_str()
14795 } else {
14796 ev.event_id.as_str()
14797 };
14798 let dedup_key = format!("inbox:{}:{}", ev.peer, id);
14799 crate::os_notify::toast_dedup(&dedup_key, &title, &body);
14800}
14801
14802#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
14803fn os_toast(title: &str, body: &str) {
14804 eprintln!("[wire notify] {title}\n {body}");
14805}
14806
14807#[cfg(test)]
14810mod relay_url_tests {
14811 use super::*;
14812
14813 #[test]
14814 fn strip_relay_url_userinfo_strips_handle_and_returns_cleaned() {
14815 assert_eq!(
14827 strip_relay_url_userinfo("https://copilot-agent@wireup.net"),
14828 "https://wireup.net",
14829 "https URL with handle userinfo is stripped to the bare host"
14830 );
14831 assert_eq!(
14832 strip_relay_url_userinfo("http://copilot-agent@127.0.0.1:8771"),
14833 "http://127.0.0.1:8771",
14834 "http + port + userinfo is stripped, port preserved"
14835 );
14836 assert_eq!(strip_relay_url_userinfo("https://u:p@host"), "https://host");
14838 assert_eq!(
14840 strip_relay_url_userinfo("https://nick@host:8443"),
14841 "https://host:8443"
14842 );
14843 assert_eq!(strip_relay_url_userinfo("nick@wireup.net"), "wireup.net");
14847 assert_eq!(
14849 strip_relay_url_userinfo("https://nick@wireup.net/v1/events?x=1#frag"),
14850 "https://wireup.net/v1/events?x=1#frag"
14851 );
14852 }
14853
14854 #[test]
14855 fn strip_relay_url_userinfo_passes_clean_urls_through_unchanged() {
14856 for ok in [
14858 "https://wireup.net",
14859 "http://wireup.net",
14860 "http://127.0.0.1:8771",
14861 "https://relay.example.com:9443/v1/wire",
14862 "https://wireup.net/?env=prod",
14863 "https://wireup.net/users/me@example.com",
14865 "https://wireup.net/?to=me@example.com",
14866 "https://wireup.net/#contact@me",
14868 "http://[::1]:8771",
14870 "wireup.net",
14872 "wireup.net:8443",
14873 ] {
14874 assert_eq!(
14875 strip_relay_url_userinfo(ok),
14876 ok,
14877 "clean URL `{ok}` must pass through unchanged"
14878 );
14879 }
14880 }
14881
14882 #[test]
14883 fn assert_relay_url_clean_for_publish_blocks_userinfo_at_persist_site() {
14884 assert!(assert_relay_url_clean_for_publish("https://wireup.net").is_ok());
14890 assert!(assert_relay_url_clean_for_publish("http://127.0.0.1:8771").is_ok());
14891 assert!(
14892 assert_relay_url_clean_for_publish("https://wireup.net/?to=me@example.com").is_ok()
14893 );
14894
14895 let err = assert_relay_url_clean_for_publish("https://nick@wireup.net")
14896 .unwrap_err()
14897 .to_string();
14898 assert!(
14899 err.contains("invariant violated"),
14900 "persist-site failure must be flagged as an internal invariant violation, not user error: {err}"
14901 );
14902 assert!(
14903 err.contains("strip_relay_url_userinfo"),
14904 "error must name the upstream filter so the caller can audit the bypass: {err}"
14905 );
14906 assert!(assert_relay_url_clean_for_publish("https://u:p@host").is_err());
14908 assert!(assert_relay_url_clean_for_publish("https://nick@host:8443").is_err());
14910 }
14911
14912 #[test]
14913 fn strip_proto_no_longer_doubles_handle_after_userinfo_fix() {
14914 let after_strip = strip_relay_url_userinfo("https://nick@wireup.net");
14920 assert_eq!(after_strip, "https://wireup.net");
14921 assert_eq!(strip_proto(&after_strip), "wireup.net");
14922 assert!(
14924 strip_proto("https://nick@wireup.net").contains('@'),
14925 "strip_proto preserves userinfo by design; the userinfo guard upstream is what prevents the doubled echo"
14926 );
14927 }
14928}
14929
14930#[cfg(test)]
14931mod self_pair_guard_tests {
14932 use super::*;
14933
14934 #[test]
14935 fn reject_self_pair_after_resolution_blocks_matching_dids() {
14936 let err = reject_self_pair_after_resolution(
14943 "did:wire:winter-bay-4092b577",
14944 "did:wire:winter-bay-4092b577",
14945 )
14946 .unwrap_err()
14947 .to_string();
14948 assert!(
14949 err.contains("refusing to self-pair"),
14950 "must explicitly refuse, not silently bail: {err}"
14951 );
14952 assert!(
14953 err.contains("did:wire:winter-bay-4092b577"),
14954 "must include the colliding DID so the operator can grep their `wire whoami` output: {err}"
14955 );
14956 assert!(
14957 err.contains("issue #30") || err.contains("issue #29"),
14958 "must point at the tracking issue so historical context is one search away: {err}"
14959 );
14960 assert!(
14963 err.contains("WIRE_SESSION_ID"),
14964 "remediation must name the env var operators set: {err}"
14965 );
14966 assert!(
14967 err.contains("uuidgen") || err.contains("NewGuid"),
14968 "remediation must include a concrete command to mint a unique id: {err}"
14969 );
14970 }
14971
14972 #[test]
14973 fn reject_self_pair_after_resolution_allows_distinct_dids() {
14974 reject_self_pair_after_resolution(
14979 "did:wire:winter-bay-4092b577",
14980 "did:wire:cedar-bayou-0616dc6c",
14981 )
14982 .unwrap();
14983 reject_self_pair_after_resolution("did:wire:ed25519:abc123", "did:wire:ed25519:def456")
14984 .unwrap();
14985 reject_self_pair_after_resolution(
14989 "did:wire:noble-canyon-deadbeef",
14990 "did:wire:noble-canyon-cafef00d",
14991 )
14992 .unwrap();
14993 }
14994}
14995
14996#[cfg(test)]
14997mod slot_reresolve_tests {
14998 use super::*;
14999
15000 #[test]
15021 fn try_reresolve_skips_when_error_is_not_4xx_shape() {
15022 let mut state = json!({"peers": {"some-peer": {"endpoints": []}}});
15023 let already = std::collections::HashSet::new();
15024 let res =
15027 try_reresolve_peer_on_slot_4xx(&mut state, "some-peer", "post failed: 502", &already)
15028 .unwrap();
15029 assert!(!res, "502 must NOT trigger a re-resolve");
15030
15031 let res =
15032 try_reresolve_peer_on_slot_4xx(&mut state, "some-peer", "connection refused", &already)
15033 .unwrap();
15034 assert!(!res, "transport errors must NOT trigger a re-resolve");
15035
15036 let res = try_reresolve_peer_on_slot_4xx(
15037 &mut state,
15038 "some-peer",
15039 "post failed: 401 Unauthorized",
15040 &already,
15041 )
15042 .unwrap();
15043 assert!(
15044 !res,
15045 "401 (auth) is a token problem, not a slot rotation — must NOT trigger a re-resolve"
15046 );
15047 }
15048
15049 #[test]
15050 fn try_reresolve_rate_limits_one_attempt_per_peer_per_push() {
15051 let mut state = json!({"peers": {"some-peer": {"endpoints": []}}});
15056 let mut already = std::collections::HashSet::new();
15057 already.insert("some-peer".to_string());
15058 let res = try_reresolve_peer_on_slot_4xx(
15059 &mut state,
15060 "some-peer",
15061 "post failed: 410 Gone",
15062 &already,
15063 )
15064 .unwrap();
15065 assert!(
15066 !res,
15067 "peer already in `already_tried` must NOT trigger another re-resolve in the same push"
15068 );
15069 }
15070
15071 #[test]
15072 fn try_reresolve_errors_when_peer_missing_from_state() {
15073 let mut state = json!({"peers": {}});
15077 let already = std::collections::HashSet::new();
15078 let err = try_reresolve_peer_on_slot_4xx(
15079 &mut state,
15080 "missing-peer",
15081 "post failed: 410 Gone",
15082 &already,
15083 )
15084 .unwrap_err()
15085 .to_string();
15086 assert!(
15087 err.contains("missing-peer") && err.contains("not in relay_state"),
15088 "missing-peer error must name the peer + the failure: {err}"
15089 );
15090 }
15091
15092 #[test]
15093 fn try_reresolve_errors_when_peer_has_no_federation_endpoint() {
15094 let mut state = json!({
15101 "peers": {
15102 "local-only": {
15103 "endpoints": [
15104 {
15105 "scope": "Local",
15106 "relay_url": "http://127.0.0.1:8771",
15107 "slot_id": "loc",
15108 "slot_token": "tok"
15109 }
15110 ]
15111 }
15112 }
15113 });
15114 let already = std::collections::HashSet::new();
15115 let err = try_reresolve_peer_on_slot_4xx(
15116 &mut state,
15117 "local-only",
15118 "post failed: 410 Gone",
15119 &already,
15120 )
15121 .unwrap_err()
15122 .to_string();
15123 assert!(
15124 err.contains("federation endpoint"),
15125 "no-federation error must name the problem: {err}"
15126 );
15127 }
15128
15129 #[test]
15145 fn error_smells_like_slot_4xx_matches_reqwest_status_display_shape() {
15146 assert!(error_smells_like_slot_4xx(
15149 "post_event failed: 410 Gone: slot rotated by peer"
15150 ));
15151 assert!(error_smells_like_slot_4xx(
15152 "post_event failed: 404 Not Found: handle no longer claimed"
15153 ));
15154 }
15155
15156 #[test]
15157 fn error_smells_like_slot_4xx_matches_uds_bare_u16_shape() {
15158 assert!(error_smells_like_slot_4xx(
15162 "post_event (uds /tmp/wire-relay.sock) failed: 410: gone"
15163 ));
15164 assert!(error_smells_like_slot_4xx(
15165 "post_event (uds /tmp/wire-relay.sock) failed: 404: not found"
15166 ));
15167 }
15168
15169 #[test]
15170 fn error_smells_like_slot_4xx_rejects_substring_lookalikes() {
15171 let false_positives = [
15175 "push aborted: slot 4101 expired",
15176 "post_event failed: 502 Bad Gateway: request_id=410abc-deadbeef",
15177 "post_event failed: 500: received 4040 bytes, expected envelope",
15178 "post_event failed: 500: event 0x4104 malformed",
15179 "post_event failed: 503: backlog=4102 entries pending",
15180 "post_event failed: 500: tx_id=4044beef",
15182 "post_event failed: 500: hash=abc410def",
15184 ];
15185 for case in false_positives {
15186 assert!(
15187 !error_smells_like_slot_4xx(case),
15188 "must NOT trigger re-resolve on substring lookalike: {case:?}"
15189 );
15190 }
15191 }
15192
15193 #[test]
15194 fn error_smells_like_slot_4xx_handles_edge_positions() {
15195 assert!(error_smells_like_slot_4xx("410 Gone"));
15197 assert!(error_smells_like_slot_4xx("404 Not Found"));
15198 assert!(error_smells_like_slot_4xx("got 410"));
15200 assert!(error_smells_like_slot_4xx("got 404"));
15201 assert!(error_smells_like_slot_4xx("post_event failed:\t410\tGone"));
15203 assert!(error_smells_like_slot_4xx("post_event failed:\n410\nGone"));
15204 assert!(error_smells_like_slot_4xx("410"));
15206 assert!(error_smells_like_slot_4xx("404"));
15207 assert!(!error_smells_like_slot_4xx(""));
15209 assert!(!error_smells_like_slot_4xx("no relevant status"));
15210 assert!(!error_smells_like_slot_4xx(
15213 "post_event failed: 401 Unauthorized"
15214 ));
15215 assert!(!error_smells_like_slot_4xx(
15216 "post_event failed: 403 Forbidden"
15217 ));
15218 assert!(!error_smells_like_slot_4xx(
15219 "post_event failed: 411 Length Required"
15220 ));
15221 }
15222}
15223
15224#[cfg(test)]
15227mod op_claims_surfacing_tests {
15228 use super::*;
15229
15230 #[test]
15231 fn op_claims_extracts_present_non_null_fields() {
15232 let card = json!({
15233 "did": "did:wire:foo-deadbeef",
15234 "handle": "foo",
15235 "op_did": "did:wire:op:foo-aaaa",
15236 "op_pubkey": "PKB64==",
15237 "op_cert": "SIGB64==",
15238 "org_memberships": [{"org_did": "did:wire:org:slancha-bbbb"}],
15239 "schema_version": "v3.2",
15240 });
15241 let claims = op_claims_from_card(&card);
15242 assert_eq!(claims.len(), 5);
15243 assert_eq!(
15244 claims.get("op_did").and_then(Value::as_str),
15245 Some("did:wire:op:foo-aaaa")
15246 );
15247 assert!(
15248 claims
15249 .get("org_memberships")
15250 .and_then(Value::as_array)
15251 .is_some()
15252 );
15253 }
15254
15255 #[test]
15256 fn op_claims_empty_on_pre_v014_card() {
15257 let card = json!({
15262 "did": "did:wire:bar-cafebabe",
15263 "handle": "bar",
15264 "capabilities": ["wire/v3.1"],
15265 });
15266 assert!(op_claims_from_card(&card).is_empty());
15267 }
15268
15269 #[test]
15270 fn op_claims_skips_explicit_null_fields() {
15271 let card = json!({
15275 "did": "did:wire:baz-12341234",
15276 "op_did": Value::Null,
15277 "org_memberships": Value::Null,
15278 "schema_version": "v3.2",
15279 });
15280 let claims = op_claims_from_card(&card);
15281 assert_eq!(claims.len(), 1);
15282 assert!(claims.get("op_did").is_none());
15283 assert!(claims.get("org_memberships").is_none());
15284 assert_eq!(
15285 claims.get("schema_version").and_then(Value::as_str),
15286 Some("v3.2")
15287 );
15288 }
15289}