1use anyhow::{Context, Result, anyhow, bail};
17use clap::{Parser, Subcommand};
18use serde_json::{Value, json};
19
20use crate::{
21 agent_card::{build_agent_card, sign_agent_card},
22 config,
23 signing::{fingerprint, generate_keypair, make_key_id, sign_message_v31, verify_message_v31},
24 trust::{add_self_to_trust, empty_trust},
25};
26
27#[derive(Parser, Debug)]
29#[command(name = "wire", version, about = "Magic-wormhole for AI agents — bilateral signed-message bus", long_about = None)]
30pub struct Cli {
31 #[command(subcommand)]
32 pub command: Command,
33}
34
35#[derive(Subcommand, Debug)]
36pub enum Command {
37 Init {
48 handle: String,
50 #[arg(long)]
52 name: Option<String>,
53 #[arg(long)]
58 relay: Option<String>,
59 #[arg(long, conflicts_with = "relay")]
64 offline: bool,
65 #[arg(long)]
67 json: bool,
68 },
69 Whoami {
73 #[arg(long)]
74 json: bool,
75 #[arg(long, conflicts_with = "json")]
78 short: bool,
79 #[arg(long, conflicts_with_all = ["json", "short"])]
82 colored: bool,
83 },
84 Peers {
86 #[arg(long)]
87 json: bool,
88 },
89 Completions {
100 #[arg(value_enum)]
102 shell: clap_complete::Shell,
103 },
104 Here {
112 #[arg(long)]
113 json: bool,
114 },
115 Pending {
120 #[arg(long)]
121 json: bool,
122 },
123 Send {
131 peer: String,
133 kind_or_body: String,
138 body: Option<String>,
142 #[arg(long)]
144 deadline: Option<String>,
145 #[arg(long)]
150 no_auto_pair: bool,
151 #[arg(long)]
153 json: bool,
154 },
155 Dial {
172 name: String,
176 message: Option<String>,
180 #[arg(long)]
182 json: bool,
183 },
184 Tail {
186 peer: Option<String>,
188 #[arg(long)]
190 json: bool,
191 #[arg(long, default_value_t = 0)]
193 limit: usize,
194 },
195 Monitor {
206 #[arg(long)]
208 peer: Option<String>,
209 #[arg(long)]
211 json: bool,
212 #[arg(long)]
215 include_handshake: bool,
216 #[arg(long, default_value_t = 500)]
218 interval_ms: u64,
219 #[arg(long, default_value_t = 0)]
221 replay: usize,
222 },
223 Verify {
225 path: String,
227 #[arg(long)]
229 json: bool,
230 },
231 Mcp,
235 RelayServer {
237 #[arg(long, default_value = "127.0.0.1:8770")]
239 bind: String,
240 #[arg(long)]
248 local_only: bool,
249 #[arg(long)]
255 uds: Option<std::path::PathBuf>,
256 },
257 BindRelay {
266 url: String,
268 #[arg(long)]
274 scope: Option<String>,
275 #[arg(long)]
281 replace: bool,
282 #[arg(long)]
288 migrate_pinned: bool,
289 #[arg(long)]
290 json: bool,
291 },
292 AddPeerSlot {
295 handle: String,
297 url: String,
299 slot_id: String,
301 slot_token: String,
303 #[arg(long)]
304 json: bool,
305 },
306 Push {
308 peer: Option<String>,
310 #[arg(long)]
311 json: bool,
312 },
313 Pull {
315 #[arg(long)]
316 json: bool,
317 },
318 Status {
321 #[arg(long)]
323 peer: Option<String>,
324 #[arg(long)]
325 json: bool,
326 },
327 Responder {
329 #[command(subcommand)]
330 command: ResponderCommand,
331 },
332 Pin {
335 card_file: String,
337 #[arg(long)]
338 json: bool,
339 },
340 RotateSlot {
351 #[arg(long)]
354 no_announce: bool,
355 #[arg(long)]
356 json: bool,
357 },
358 ForgetPeer {
362 handle: String,
364 #[arg(long)]
366 purge: bool,
367 #[arg(long)]
368 json: bool,
369 },
370 Daemon {
374 #[arg(long, default_value_t = 5)]
376 interval: u64,
377 #[arg(long)]
379 once: bool,
380 #[arg(long)]
381 json: bool,
382 },
383 #[command(hide = true)] PairHost {
389 #[arg(long)]
391 relay: String,
392 #[arg(long)]
396 yes: bool,
397 #[arg(long, default_value_t = 300)]
399 timeout: u64,
400 #[arg(long)]
406 detach: bool,
407 #[arg(long)]
409 json: bool,
410 },
411 #[command(alias = "join")]
415 #[command(hide = true)] PairJoin {
417 code_phrase: String,
419 #[arg(long)]
421 relay: String,
422 #[arg(long)]
423 yes: bool,
424 #[arg(long, default_value_t = 300)]
425 timeout: u64,
426 #[arg(long)]
428 detach: bool,
429 #[arg(long)]
431 json: bool,
432 },
433 #[command(hide = true)] PairConfirm {
438 code_phrase: String,
440 digits: String,
442 #[arg(long)]
444 json: bool,
445 },
446 #[command(hide = true)] PairList {
449 #[arg(long)]
451 json: bool,
452 #[arg(long)]
456 watch: bool,
457 #[arg(long, default_value_t = 1)]
459 watch_interval: u64,
460 },
461 #[command(hide = true)] PairCancel {
464 code_phrase: String,
465 #[arg(long)]
466 json: bool,
467 },
468 #[command(hide = true)] PairWatch {
479 code_phrase: String,
480 #[arg(long, default_value = "sas_ready")]
482 status: String,
483 #[arg(long, default_value_t = 300)]
485 timeout: u64,
486 #[arg(long)]
488 json: bool,
489 },
490 #[command(hide = true)] Pair {
504 handle: String,
507 #[arg(long)]
510 code: Option<String>,
511 #[arg(long, default_value = "https://wireup.net")]
513 relay: String,
514 #[arg(long)]
516 yes: bool,
517 #[arg(long, default_value_t = 300)]
519 timeout: u64,
520 #[arg(long)]
523 no_setup: bool,
524 #[arg(long)]
529 detach: bool,
530 },
531 #[command(hide = true)] PairAbandon {
538 code_phrase: String,
540 #[arg(long, default_value = "https://wireup.net")]
542 relay: String,
543 },
544 #[command(hide = true)] PairAccept {
551 peer: String,
553 #[arg(long)]
555 json: bool,
556 },
557 #[command(hide = true)] PairReject {
565 peer: String,
567 #[arg(long)]
569 json: bool,
570 },
571 #[command(hide = true)] PairListInbound {
578 #[arg(long)]
580 json: bool,
581 },
582 #[command(subcommand)]
592 Session(SessionCommand),
593 Identity {
598 #[command(subcommand)]
599 cmd: IdentityCommand,
600 },
601 #[command(subcommand)]
606 Mesh(MeshCommand),
607 Setup {
612 #[arg(long)]
614 apply: bool,
615 },
616 Whois {
620 handle: Option<String>,
622 #[arg(long)]
623 json: bool,
624 #[arg(long)]
627 relay: Option<String>,
628 },
629 Add {
635 handle: String,
638 #[arg(long)]
640 relay: Option<String>,
641 #[arg(long)]
649 local_sister: bool,
650 #[arg(long)]
651 json: bool,
652 },
653 Up {
663 handle: String,
666 #[arg(long)]
668 name: Option<String>,
669 #[arg(long)]
674 with_local: Option<String>,
675 #[arg(long)]
677 no_local: bool,
678 #[arg(long)]
679 json: bool,
680 },
681 Doctor {
688 #[arg(long)]
690 json: bool,
691 #[arg(long, default_value_t = 5)]
693 recent_rejections: usize,
694 },
695 Upgrade {
700 #[arg(long)]
703 check: bool,
704 #[arg(long)]
705 json: bool,
706 },
707 Service {
712 #[command(subcommand)]
713 action: ServiceAction,
714 },
715 Diag {
720 #[command(subcommand)]
721 action: DiagAction,
722 },
723 Claim {
727 nick: String,
728 #[arg(long)]
730 relay: Option<String>,
731 #[arg(long)]
733 public_url: Option<String>,
734 #[arg(long)]
742 hidden: bool,
743 #[arg(long)]
744 json: bool,
745 },
746 Profile {
756 #[command(subcommand)]
757 action: ProfileAction,
758 },
759 #[command(hide = true)] Invite {
764 #[arg(long, default_value = "https://wireup.net")]
766 relay: String,
767 #[arg(long, default_value_t = 86_400)]
769 ttl: u64,
770 #[arg(long, default_value_t = 1)]
773 uses: u32,
774 #[arg(long)]
778 share: bool,
779 #[arg(long)]
781 json: bool,
782 },
783 Accept {
793 target: String,
795 #[arg(long)]
797 json: bool,
798 },
799 #[command(alias = "invite-accept")]
807 AcceptInvite {
808 url: String,
810 #[arg(long)]
812 json: bool,
813 },
814 Reject {
817 peer: String,
819 #[arg(long)]
821 json: bool,
822 },
823 Reactor {
829 #[arg(long)]
831 on_event: String,
832 #[arg(long)]
834 peer: Option<String>,
835 #[arg(long)]
837 kind: Option<String>,
838 #[arg(long, default_value_t = true)]
840 verified_only: bool,
841 #[arg(long, default_value_t = 2)]
843 interval: u64,
844 #[arg(long)]
846 once: bool,
847 #[arg(long)]
849 dry_run: bool,
850 #[arg(long, default_value_t = 6)]
854 max_per_minute: u32,
855 #[arg(long, default_value_t = 1)]
859 max_chain_depth: u32,
860 },
861 Notify {
866 #[arg(long, default_value_t = 2)]
868 interval: u64,
869 #[arg(long)]
871 peer: Option<String>,
872 #[arg(long)]
874 once: bool,
875 #[arg(long)]
879 json: bool,
880 },
881}
882
883#[derive(Subcommand, Debug)]
884pub enum DiagAction {
885 Tail {
887 #[arg(long, default_value_t = 20)]
888 limit: usize,
889 #[arg(long)]
890 json: bool,
891 },
892 Enable,
895 Disable,
897 Status {
899 #[arg(long)]
900 json: bool,
901 },
902}
903
904#[derive(Subcommand, Debug)]
905pub enum IdentityCommand {
906 Show {
909 #[arg(long)]
910 json: bool,
911 },
912 List {
917 #[arg(long)]
918 json: bool,
919 },
920 Publish {
926 nick: String,
928 #[arg(long)]
931 relay: Option<String>,
932 #[arg(long, alias = "public")]
935 public_url: Option<String>,
936 #[arg(long)]
940 hidden: bool,
941 #[arg(long)]
942 json: bool,
943 },
944 Destroy {
948 name: String,
950 #[arg(long)]
952 force: bool,
953 #[arg(long)]
954 json: bool,
955 },
956 Create {
968 #[arg(long)]
971 name: Option<String>,
972 #[arg(long, conflicts_with = "local")]
975 anonymous: bool,
976 #[arg(long)]
979 local: bool,
980 #[arg(long)]
981 json: bool,
982 },
983 Persist {
988 name: String,
990 #[arg(long = "as", value_name = "NEW_NAME")]
992 as_name: Option<String>,
993 #[arg(long)]
994 json: bool,
995 },
996 Demote {
1006 name: String,
1008 #[arg(long)]
1009 json: bool,
1010 },
1011}
1012
1013#[derive(Subcommand, Debug)]
1014pub enum SessionCommand {
1015 New {
1023 name: Option<String>,
1025 #[arg(long, default_value = "https://wireup.net")]
1027 relay: String,
1028 #[arg(long)]
1035 with_local: bool,
1036 #[arg(long, default_value = "http://127.0.0.1:8771")]
1040 local_relay: String,
1041 #[arg(long)]
1048 with_lan: bool,
1049 #[arg(long)]
1053 lan_relay: Option<String>,
1054 #[arg(long)]
1061 with_uds: bool,
1062 #[arg(long)]
1066 uds_socket: Option<std::path::PathBuf>,
1067 #[arg(long)]
1070 no_daemon: bool,
1071 #[arg(long)]
1079 local_only: bool,
1080 #[arg(long)]
1082 json: bool,
1083 },
1084 List {
1087 #[arg(long)]
1088 json: bool,
1089 },
1090 ListLocal {
1096 #[arg(long)]
1097 json: bool,
1098 },
1099 PairAllLocal {
1115 #[arg(long, default_value_t = 1)]
1120 settle_secs: u64,
1121 #[arg(long, default_value = "https://wireup.net")]
1126 federation_relay: String,
1127 #[arg(long)]
1128 json: bool,
1129 },
1130 MeshStatus {
1144 #[arg(long, default_value_t = 300)]
1149 stale_secs: u64,
1150 #[arg(long)]
1151 json: bool,
1152 },
1153 Env {
1157 name: Option<String>,
1159 #[arg(long)]
1160 json: bool,
1161 },
1162 Current {
1166 #[arg(long)]
1167 json: bool,
1168 },
1169 Bind {
1177 name: Option<String>,
1181 #[arg(long)]
1182 json: bool,
1183 },
1184 Destroy {
1188 name: String,
1189 #[arg(long)]
1191 force: bool,
1192 #[arg(long)]
1193 json: bool,
1194 },
1195}
1196
1197#[derive(Subcommand, Debug)]
1202pub enum MeshCommand {
1203 Status {
1206 #[arg(long, default_value_t = 300)]
1208 stale_secs: u64,
1209 #[arg(long)]
1210 json: bool,
1211 },
1212 Broadcast {
1231 #[arg(long, default_value = "claim")]
1234 kind: String,
1235 #[arg(long, default_value = "local")]
1237 scope: String,
1238 #[arg(long)]
1240 exclude: Vec<String>,
1241 #[arg(long)]
1245 noreply: bool,
1246 body: String,
1248 #[arg(long)]
1249 json: bool,
1250 },
1251 Role {
1260 #[command(subcommand)]
1261 action: MeshRoleAction,
1262 },
1263 Route {
1279 role: String,
1281 #[arg(long, default_value = "round-robin")]
1283 strategy: String,
1284 #[arg(long)]
1286 exclude: Vec<String>,
1287 #[arg(long, default_value = "claim")]
1290 kind: String,
1291 body: String,
1293 #[arg(long)]
1294 json: bool,
1295 },
1296}
1297
1298#[derive(Subcommand, Debug)]
1300pub enum MeshRoleAction {
1301 Set {
1306 role: String,
1307 #[arg(long)]
1308 json: bool,
1309 },
1310 Get {
1313 peer: Option<String>,
1314 #[arg(long)]
1315 json: bool,
1316 },
1317 List {
1320 #[arg(long)]
1321 json: bool,
1322 },
1323 Clear {
1326 #[arg(long)]
1327 json: bool,
1328 },
1329}
1330
1331#[derive(Subcommand, Debug)]
1332pub enum ServiceAction {
1333 Install {
1343 #[arg(long)]
1345 local_relay: bool,
1346 #[arg(long)]
1347 json: bool,
1348 },
1349 Uninstall {
1353 #[arg(long)]
1355 local_relay: bool,
1356 #[arg(long)]
1357 json: bool,
1358 },
1359 Status {
1361 #[arg(long)]
1363 local_relay: bool,
1364 #[arg(long)]
1365 json: bool,
1366 },
1367}
1368
1369#[derive(Subcommand, Debug)]
1370pub enum ResponderCommand {
1371 Set {
1373 status: String,
1375 #[arg(long)]
1377 reason: Option<String>,
1378 #[arg(long)]
1380 json: bool,
1381 },
1382 Get {
1384 peer: Option<String>,
1386 #[arg(long)]
1388 json: bool,
1389 },
1390}
1391
1392#[derive(Subcommand, Debug)]
1393pub enum ProfileAction {
1394 Set {
1398 field: String,
1399 value: String,
1400 #[arg(long)]
1401 json: bool,
1402 },
1403 Get {
1405 #[arg(long)]
1406 json: bool,
1407 },
1408 Clear {
1410 field: String,
1411 #[arg(long)]
1412 json: bool,
1413 },
1414}
1415
1416pub fn run() -> Result<()> {
1418 crate::session::maybe_adopt_session_wire_home("cli");
1429 let cli = Cli::parse();
1430 match cli.command {
1431 Command::Init {
1432 handle,
1433 name,
1434 relay,
1435 offline,
1436 json,
1437 } => cmd_init(&handle, name.as_deref(), relay.as_deref(), offline, json),
1438 Command::Status { peer, json } => {
1439 if let Some(peer) = peer {
1440 cmd_status_peer(&peer, json)
1441 } else {
1442 cmd_status(json)
1443 }
1444 }
1445 Command::Whoami {
1446 json,
1447 short,
1448 colored,
1449 } => cmd_whoami(json_default(json), short, colored),
1450 Command::Peers { json } => cmd_peers(json_default(json)),
1451 Command::Here { json } => cmd_here(json_default(json)),
1452 Command::Completions { shell } => {
1453 use clap::CommandFactory;
1460 let mut cmd = Cli::command();
1461 clap_complete::generate(shell, &mut cmd, "wire", &mut std::io::stdout());
1462 Ok(())
1463 }
1464 Command::Pending { json } => cmd_pair_list_inbound(json_default(json)),
1465 Command::Reject { peer, json } => cmd_pair_reject(&peer, json_default(json)),
1466 Command::Send {
1467 peer,
1468 kind_or_body,
1469 body,
1470 deadline,
1471 no_auto_pair,
1472 json,
1473 } => {
1474 let (kind, body) = match body {
1477 Some(real_body) => (kind_or_body, real_body),
1478 None => ("claim".to_string(), kind_or_body),
1479 };
1480 cmd_send(
1481 &peer,
1482 &kind,
1483 &body,
1484 deadline.as_deref(),
1485 no_auto_pair,
1486 json_default(json),
1487 )
1488 }
1489 Command::Dial {
1490 name,
1491 message,
1492 json,
1493 } => cmd_dial(&name, message.as_deref(), json_default(json)),
1494 Command::Tail { peer, json, limit } => cmd_tail(peer.as_deref(), json, limit),
1495 Command::Monitor {
1496 peer,
1497 json,
1498 include_handshake,
1499 interval_ms,
1500 replay,
1501 } => cmd_monitor(
1502 peer.as_deref(),
1503 json,
1504 include_handshake,
1505 interval_ms,
1506 replay,
1507 ),
1508 Command::Verify { path, json } => cmd_verify(&path, json),
1509 Command::Responder { command } => match command {
1510 ResponderCommand::Set {
1511 status,
1512 reason,
1513 json,
1514 } => cmd_responder_set(&status, reason.as_deref(), json),
1515 ResponderCommand::Get { peer, json } => cmd_responder_get(peer.as_deref(), json),
1516 },
1517 Command::Mcp => cmd_mcp(),
1518 Command::RelayServer {
1519 bind,
1520 local_only,
1521 uds,
1522 } => cmd_relay_server(&bind, local_only, uds.as_deref()),
1523 Command::BindRelay {
1524 url,
1525 scope,
1526 replace,
1527 migrate_pinned,
1528 json,
1529 } => cmd_bind_relay(&url, scope.as_deref(), replace, migrate_pinned, json),
1530 Command::AddPeerSlot {
1531 handle,
1532 url,
1533 slot_id,
1534 slot_token,
1535 json,
1536 } => cmd_add_peer_slot(&handle, &url, &slot_id, &slot_token, json),
1537 Command::Push { peer, json } => cmd_push(peer.as_deref(), json),
1538 Command::Pull { json } => cmd_pull(json),
1539 Command::Pin { card_file, json } => cmd_pin(&card_file, json),
1540 Command::RotateSlot { no_announce, json } => cmd_rotate_slot(no_announce, json),
1541 Command::ForgetPeer {
1542 handle,
1543 purge,
1544 json,
1545 } => cmd_forget_peer(&handle, purge, json),
1546 Command::Daemon {
1547 interval,
1548 once,
1549 json,
1550 } => cmd_daemon(interval, once, json),
1551 Command::PairHost {
1552 relay,
1553 yes,
1554 timeout,
1555 detach,
1556 json,
1557 } => {
1558 if detach {
1559 cmd_pair_host_detach(&relay, json)
1560 } else {
1561 cmd_pair_host(&relay, yes, timeout)
1562 }
1563 }
1564 Command::PairJoin {
1565 code_phrase,
1566 relay,
1567 yes,
1568 timeout,
1569 detach,
1570 json,
1571 } => {
1572 if detach {
1573 cmd_pair_join_detach(&code_phrase, &relay, json)
1574 } else {
1575 cmd_pair_join(&code_phrase, &relay, yes, timeout)
1576 }
1577 }
1578 Command::PairConfirm {
1579 code_phrase,
1580 digits,
1581 json,
1582 } => cmd_pair_confirm(&code_phrase, &digits, json),
1583 Command::PairList {
1584 json,
1585 watch,
1586 watch_interval,
1587 } => cmd_pair_list(json, watch, watch_interval),
1588 Command::PairCancel { code_phrase, json } => cmd_pair_cancel(&code_phrase, json),
1589 Command::PairWatch {
1590 code_phrase,
1591 status,
1592 timeout,
1593 json,
1594 } => cmd_pair_watch(&code_phrase, &status, timeout, json),
1595 Command::Pair {
1596 handle,
1597 code,
1598 relay,
1599 yes,
1600 timeout,
1601 no_setup,
1602 detach,
1603 } => {
1604 if handle.contains('@') && code.is_none() {
1611 cmd_pair_megacommand(&handle, Some(&relay), timeout, false)
1612 } else if detach {
1613 cmd_pair_detach(&handle, code.as_deref(), &relay)
1614 } else {
1615 cmd_pair(&handle, code.as_deref(), &relay, yes, timeout, no_setup)
1616 }
1617 }
1618 Command::PairAbandon { code_phrase, relay } => cmd_pair_abandon(&code_phrase, &relay),
1619 Command::PairAccept { peer, json } => {
1620 let j = json_default(json);
1621 deprecation_warn("pair-accept", &format!("accept {peer}"), j);
1622 cmd_pair_accept(&peer, j)
1623 }
1624 Command::PairReject { peer, json } => {
1625 let j = json_default(json);
1626 deprecation_warn("pair-reject", &format!("reject {peer}"), j);
1627 cmd_pair_reject(&peer, j)
1628 }
1629 Command::PairListInbound { json } => {
1630 let j = json_default(json);
1631 deprecation_warn("pair-list-inbound", "pending", j);
1632 cmd_pair_list_inbound(j)
1633 }
1634 Command::Session(cmd) => cmd_session(cmd),
1635 Command::Identity { cmd } => cmd_identity(cmd),
1636 Command::Mesh(cmd) => cmd_mesh(cmd),
1637 Command::Invite {
1638 relay,
1639 ttl,
1640 uses,
1641 share,
1642 json,
1643 } => cmd_invite(&relay, ttl, uses, share, json),
1644 Command::Accept { target, json } => {
1645 let j = json_default(json);
1651 if target.starts_with("wire://pair?") {
1652 deprecation_warn("accept-url", "accept-invite <url>", j);
1653 cmd_accept(&target, j)
1654 } else {
1655 cmd_pair_accept(&target, j)
1656 }
1657 }
1658 Command::AcceptInvite { url, json } => cmd_accept(&url, json_default(json)),
1659 Command::Whois {
1660 handle,
1661 json,
1662 relay,
1663 } => {
1664 match handle.as_deref() {
1673 Some(h) if !h.contains('@') => cmd_whois_local(h, json),
1674 other => cmd_whois(other, json, relay.as_deref()),
1675 }
1676 }
1677 Command::Add {
1678 handle,
1679 relay,
1680 local_sister,
1681 json,
1682 } => cmd_add(&handle, relay.as_deref(), local_sister, json),
1683 Command::Up {
1684 handle,
1685 name,
1686 with_local,
1687 no_local,
1688 json,
1689 } => cmd_up(
1690 &handle,
1691 name.as_deref(),
1692 with_local.as_deref(),
1693 no_local,
1694 json,
1695 ),
1696 Command::Doctor {
1697 json,
1698 recent_rejections,
1699 } => cmd_doctor(json, recent_rejections),
1700 Command::Upgrade { check, json } => cmd_upgrade(check, json),
1701 Command::Service { action } => cmd_service(action),
1702 Command::Diag { action } => cmd_diag(action),
1703 Command::Claim {
1704 nick,
1705 relay,
1706 public_url,
1707 hidden,
1708 json,
1709 } => cmd_claim(&nick, relay.as_deref(), public_url.as_deref(), hidden, json),
1710 Command::Profile { action } => cmd_profile(action),
1711 Command::Setup { apply } => cmd_setup(apply),
1712 Command::Reactor {
1713 on_event,
1714 peer,
1715 kind,
1716 verified_only,
1717 interval,
1718 once,
1719 dry_run,
1720 max_per_minute,
1721 max_chain_depth,
1722 } => cmd_reactor(
1723 &on_event,
1724 peer.as_deref(),
1725 kind.as_deref(),
1726 verified_only,
1727 interval,
1728 once,
1729 dry_run,
1730 max_per_minute,
1731 max_chain_depth,
1732 ),
1733 Command::Notify {
1734 interval,
1735 peer,
1736 once,
1737 json,
1738 } => cmd_notify(interval, peer.as_deref(), once, json),
1739 }
1740}
1741
1742fn cmd_init(
1745 handle: &str,
1746 name: Option<&str>,
1747 relay: Option<&str>,
1748 offline: bool,
1749 as_json: bool,
1750) -> Result<()> {
1751 if !handle
1752 .chars()
1753 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
1754 {
1755 bail!("handle must be ASCII alphanumeric / '-' / '_' (got {handle:?})");
1756 }
1757 if config::is_initialized()? {
1758 bail!(
1759 "already initialized — config exists at {:?}. Delete it first if you want a fresh identity.",
1760 config::config_dir()?
1761 );
1762 }
1763 let mut resolved_relay: Option<String> = relay.map(str::to_string);
1777 if resolved_relay.is_none() && !offline {
1778 let default_local = "http://127.0.0.1:8771";
1779 let client = crate::relay_client::RelayClient::new(default_local);
1780 if client.check_healthz().is_ok() {
1781 eprintln!(
1782 "wire init: local relay at {default_local} reachable — auto-attaching. \
1783 Use --relay <url> to pick a different relay, --offline to skip."
1784 );
1785 resolved_relay = Some(default_local.to_string());
1786 } else {
1787 use std::io::{BufRead, IsTerminal, Write};
1793 let interactive = std::io::stdin().is_terminal() && std::io::stderr().is_terminal();
1794 if interactive && std::env::var("WIRE_NO_INTERACTIVE").is_err() {
1795 eprintln!("wire init: no local relay reachable at {default_local}.");
1796 eprint!(
1797 " Bind to public federation relay https://wireup.net instead? \
1798 [Y/n/offline/url]: "
1799 );
1800 let _ = std::io::stderr().flush();
1801 let mut input = String::new();
1802 let _ = std::io::stdin().lock().read_line(&mut input);
1803 let answer = input.trim();
1804 match answer {
1805 "" | "y" | "Y" | "yes" | "YES" => {
1806 eprintln!("wire init: binding to https://wireup.net");
1807 resolved_relay = Some("https://wireup.net".to_string());
1808 }
1809 "n" | "N" | "no" | "NO" => {
1810 bail!(
1811 "wire init: declined federation default; re-run with --relay <url> or --offline."
1812 );
1813 }
1814 "offline" | "OFFLINE" => {
1815 eprintln!(
1816 "wire init: proceeding offline. \
1817 Run `wire bind-relay <url>` before pairing."
1818 );
1819 }
1825 url if url.starts_with("http://") || url.starts_with("https://") => {
1826 eprintln!("wire init: binding to {url}");
1827 resolved_relay = Some(url.to_string());
1828 }
1829 other => {
1830 bail!(
1831 "wire init: unrecognized answer `{other}` — \
1832 expected Y/n/offline/<url>. Re-run with --relay or --offline."
1833 );
1834 }
1835 }
1836 } else {
1837 bail!(
1838 "wire init: no relay specified and no local relay reachable at \
1839 http://127.0.0.1:8771.\n\
1840 Pick one:\n\
1841 • `wire service install --local-relay` — start the local relay, then re-run\n\
1842 • `wire init {handle} --relay https://wireup.net` — bind to public federation\n\
1843 • `wire init {handle} --offline` — generate keypair only \
1844 (peers cannot reach you until you `wire bind-relay <url>` later)"
1845 );
1846 }
1847 }
1848 }
1849 let relay = resolved_relay.as_deref();
1850
1851 config::ensure_dirs()?;
1852 let (sk_seed, pk_bytes) = generate_keypair();
1853 config::write_private_key(&sk_seed)?;
1854
1855 let synth_did = crate::agent_card::did_for_with_key(handle, &pk_bytes);
1870 let character = crate::character::Character::from_did(&synth_did);
1871 let canonical_handle: &str = &character.nickname;
1872 if canonical_handle != handle {
1873 eprintln!(
1874 "wire init: v0.11 one-name rule — operator-typed `{handle}` ignored in favor of \
1875 DID-derived character `{canonical_handle}`. Peers will reach you as `{canonical_handle}`."
1876 );
1877 }
1878
1879 let card = build_agent_card(canonical_handle, &pk_bytes, name, None, None);
1880 let signed = sign_agent_card(&card, &sk_seed);
1881 config::write_agent_card(&signed)?;
1882
1883 let mut trust = empty_trust();
1884 add_self_to_trust(&mut trust, canonical_handle, &pk_bytes);
1885 config::write_trust(&trust)?;
1886
1887 let fp = fingerprint(&pk_bytes);
1888 let key_id = make_key_id(canonical_handle, &pk_bytes);
1889 let handle = canonical_handle;
1892
1893 let mut relay_info: Option<(String, String)> = None;
1895 if let Some(url) = relay {
1896 let normalized = url.trim_end_matches('/');
1897 let client = crate::relay_client::RelayClient::new(normalized);
1898 client.check_healthz()?;
1899 let alloc = client.allocate_slot(Some(handle))?;
1900 let mut state = config::read_relay_state()?;
1901 state["self"] = json!({
1902 "relay_url": normalized,
1903 "slot_id": alloc.slot_id.clone(),
1904 "slot_token": alloc.slot_token,
1905 });
1906 config::write_relay_state(&state)?;
1907 relay_info = Some((normalized.to_string(), alloc.slot_id));
1908 }
1909
1910 let did_str = crate::agent_card::did_for_with_key(handle, &pk_bytes);
1911 if as_json {
1912 let mut out = json!({
1913 "did": did_str.clone(),
1914 "fingerprint": fp,
1915 "key_id": key_id,
1916 "config_dir": config::config_dir()?.to_string_lossy(),
1917 });
1918 if let Some((url, slot_id)) = &relay_info {
1919 out["relay_url"] = json!(url);
1920 out["slot_id"] = json!(slot_id);
1921 }
1922 println!("{}", serde_json::to_string(&out)?);
1923 } else {
1924 println!("generated {did_str} (ed25519:{key_id})");
1925 println!(
1926 "config written to {}",
1927 config::config_dir()?.to_string_lossy()
1928 );
1929 if let Some((url, slot_id)) = &relay_info {
1930 println!("bound to relay {url} (slot {slot_id})");
1931 println!();
1932 println!(
1933 "next step: `wire pair-host --relay {url}` to print a code phrase for a peer."
1934 );
1935 } else {
1936 println!();
1937 println!(
1938 "next step: `wire pair-host --relay <url>` to bind a relay + open a pair-slot."
1939 );
1940 }
1941 }
1942 Ok(())
1943}
1944
1945fn cmd_status(as_json: bool) -> Result<()> {
1948 let initialized = config::is_initialized()?;
1949
1950 let mut summary = json!({
1951 "initialized": initialized,
1952 });
1953
1954 if initialized {
1955 let card = config::read_agent_card()?;
1956 let did = card
1957 .get("did")
1958 .and_then(Value::as_str)
1959 .unwrap_or("")
1960 .to_string();
1961 let handle = card
1965 .get("handle")
1966 .and_then(Value::as_str)
1967 .map(str::to_string)
1968 .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
1969 let pk_b64 = card
1970 .get("verify_keys")
1971 .and_then(Value::as_object)
1972 .and_then(|m| m.values().next())
1973 .and_then(|v| v.get("key"))
1974 .and_then(Value::as_str)
1975 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
1976 let pk_bytes = crate::signing::b64decode(pk_b64)?;
1977 summary["did"] = json!(did);
1978 summary["handle"] = json!(handle);
1979 summary["fingerprint"] = json!(fingerprint(&pk_bytes));
1980 summary["capabilities"] = card
1981 .get("capabilities")
1982 .cloned()
1983 .unwrap_or_else(|| json!([]));
1984
1985 let trust = config::read_trust()?;
1986 let relay_state_for_tier =
1987 config::read_relay_state().unwrap_or_else(|_| json!({"peers": {}}));
1988 let mut peers = Vec::new();
1989 if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
1990 for (peer_handle, _agent) in agents {
1991 if peer_handle == &handle {
1992 continue; }
1994 peers.push(json!({
1999 "handle": peer_handle,
2000 "tier": effective_peer_tier(&trust, &relay_state_for_tier, peer_handle),
2001 }));
2002 }
2003 }
2004 summary["peers"] = json!(peers);
2005
2006 let relay_state = config::read_relay_state()?;
2007 summary["self_relay"] = relay_state.get("self").cloned().unwrap_or(Value::Null);
2008 if !summary["self_relay"].is_null() {
2009 if let Some(obj) = summary["self_relay"].as_object_mut() {
2011 obj.remove("slot_token");
2012 }
2013 }
2014 summary["peer_slots_count"] = json!(
2015 relay_state
2016 .get("peers")
2017 .and_then(Value::as_object)
2018 .map(|m| m.len())
2019 .unwrap_or(0)
2020 );
2021
2022 let outbox = config::outbox_dir()?;
2024 let inbox = config::inbox_dir()?;
2025 summary["outbox"] = json!(scan_jsonl_dir(&outbox)?);
2026 summary["inbox"] = json!(scan_jsonl_dir(&inbox)?);
2027
2028 let snap = crate::ensure_up::daemon_liveness();
2034 let mut daemon = json!({
2035 "running": snap.pidfile_alive,
2036 "pid": snap.pidfile_pid,
2037 "all_running_pids": snap.pgrep_pids,
2038 "orphans": snap.orphan_pids,
2039 });
2040 if let crate::ensure_up::PidRecord::Json(d) = &snap.record {
2041 daemon["version"] = json!(d.version);
2042 daemon["bin_path"] = json!(d.bin_path);
2043 daemon["did"] = json!(d.did);
2044 daemon["relay_url"] = json!(d.relay_url);
2045 daemon["started_at"] = json!(d.started_at);
2046 daemon["schema"] = json!(d.schema);
2047 if d.version != env!("CARGO_PKG_VERSION") {
2048 daemon["version_mismatch"] = json!({
2049 "daemon": d.version.clone(),
2050 "cli": env!("CARGO_PKG_VERSION"),
2051 });
2052 }
2053 } else if matches!(snap.record, crate::ensure_up::PidRecord::LegacyInt(_)) {
2054 daemon["pidfile_form"] = json!("legacy-int");
2055 daemon["version_mismatch"] = json!({
2056 "daemon": "<pre-0.5.11>",
2057 "cli": env!("CARGO_PKG_VERSION"),
2058 });
2059 }
2060 summary["daemon"] = daemon;
2061
2062 let pending = crate::pending_pair::list_pending().unwrap_or_default();
2064 let mut counts: std::collections::BTreeMap<String, u32> = Default::default();
2065 for p in &pending {
2066 *counts.entry(p.status.clone()).or_default() += 1;
2067 }
2068 let pending_inbound =
2070 crate::pending_inbound_pair::list_pending_inbound().unwrap_or_default();
2071 let inbound_handles: Vec<&str> = pending_inbound
2072 .iter()
2073 .map(|p| p.peer_handle.as_str())
2074 .collect();
2075 summary["pending_pairs"] = json!({
2076 "total": pending.len(),
2077 "by_status": counts,
2078 "inbound_count": pending_inbound.len(),
2079 "inbound_handles": inbound_handles,
2080 });
2081 }
2082
2083 if as_json {
2084 println!("{}", serde_json::to_string(&summary)?);
2085 } else if !initialized {
2086 println!("not initialized — run `wire init <handle>` first");
2087 } else {
2088 println!("did: {}", summary["did"].as_str().unwrap_or("?"));
2089 println!(
2090 "fingerprint: {}",
2091 summary["fingerprint"].as_str().unwrap_or("?")
2092 );
2093 println!("capabilities: {}", summary["capabilities"]);
2094 if !summary["self_relay"].is_null() {
2095 println!(
2096 "self relay: {} (slot {})",
2097 summary["self_relay"]["relay_url"].as_str().unwrap_or("?"),
2098 summary["self_relay"]["slot_id"].as_str().unwrap_or("?")
2099 );
2100 } else {
2101 println!("self relay: (not bound — run `wire pair-host --relay <url>` to bind)");
2102 }
2103 println!(
2104 "peers: {}",
2105 summary["peers"].as_array().map(|a| a.len()).unwrap_or(0)
2106 );
2107 for p in summary["peers"].as_array().unwrap_or(&Vec::new()) {
2108 println!(
2109 " - {:<20} tier={}",
2110 p["handle"].as_str().unwrap_or(""),
2111 p["tier"].as_str().unwrap_or("?")
2112 );
2113 }
2114 println!(
2115 "outbox: {} file(s), {} event(s) queued",
2116 summary["outbox"]["files"].as_u64().unwrap_or(0),
2117 summary["outbox"]["events"].as_u64().unwrap_or(0)
2118 );
2119 println!(
2120 "inbox: {} file(s), {} event(s) received",
2121 summary["inbox"]["files"].as_u64().unwrap_or(0),
2122 summary["inbox"]["events"].as_u64().unwrap_or(0)
2123 );
2124 let daemon_running = summary["daemon"]["running"].as_bool().unwrap_or(false);
2125 let daemon_pid = summary["daemon"]["pid"]
2126 .as_u64()
2127 .map(|p| p.to_string())
2128 .unwrap_or_else(|| "—".to_string());
2129 let daemon_version = summary["daemon"]["version"].as_str().unwrap_or("");
2130 let version_suffix = if !daemon_version.is_empty() {
2131 format!(" v{daemon_version}")
2132 } else {
2133 String::new()
2134 };
2135 println!(
2136 "daemon: {} (pid {}{})",
2137 if daemon_running { "running" } else { "DOWN" },
2138 daemon_pid,
2139 version_suffix,
2140 );
2141 if let Some(mm) = summary["daemon"].get("version_mismatch") {
2143 println!(
2144 " !! version mismatch: daemon={} CLI={}. \
2145 run `wire upgrade` to swap atomically.",
2146 mm["daemon"].as_str().unwrap_or("?"),
2147 mm["cli"].as_str().unwrap_or("?"),
2148 );
2149 }
2150 if let Some(orphans) = summary["daemon"]["orphans"].as_array()
2151 && !orphans.is_empty()
2152 {
2153 let pids: Vec<String> = orphans
2154 .iter()
2155 .filter_map(|v| v.as_u64().map(|p| p.to_string()))
2156 .collect();
2157 println!(
2158 " !! orphan daemon process(es): pids {}. \
2159 pgrep saw them but pidfile didn't — likely stale process from \
2160 prior install. Multiple daemons race the relay cursor.",
2161 pids.join(", ")
2162 );
2163 }
2164 let pending_total = summary["pending_pairs"]["total"].as_u64().unwrap_or(0);
2165 let inbound_count = summary["pending_pairs"]["inbound_count"]
2166 .as_u64()
2167 .unwrap_or(0);
2168 if pending_total > 0 {
2169 print!("pending pairs: {pending_total}");
2170 if let Some(obj) = summary["pending_pairs"]["by_status"].as_object() {
2171 let parts: Vec<String> = obj
2172 .iter()
2173 .map(|(k, v)| format!("{}={}", k, v.as_u64().unwrap_or(0)))
2174 .collect();
2175 if !parts.is_empty() {
2176 print!(" ({})", parts.join(", "));
2177 }
2178 }
2179 println!();
2180 } else if inbound_count == 0 {
2181 println!("pending pairs: none");
2182 }
2183 if inbound_count > 0 {
2187 let handles: Vec<String> = summary["pending_pairs"]["inbound_handles"]
2188 .as_array()
2189 .map(|a| {
2190 a.iter()
2191 .filter_map(|v| v.as_str().map(str::to_string))
2192 .collect()
2193 })
2194 .unwrap_or_default();
2195 println!(
2196 "inbound pair requests ({inbound_count}): {} — `wire pair-list` to inspect, `wire pair-accept <peer>` to accept, `wire pair-reject <peer>` to refuse",
2197 handles.join(", "),
2198 );
2199 }
2200 }
2201 Ok(())
2202}
2203
2204fn scan_jsonl_dir(dir: &std::path::Path) -> Result<Value> {
2205 if !dir.exists() {
2206 return Ok(json!({"files": 0, "events": 0}));
2207 }
2208 let mut files = 0usize;
2209 let mut events = 0usize;
2210 for entry in std::fs::read_dir(dir)? {
2211 let path = entry?.path();
2212 if path.extension().map(|x| x == "jsonl").unwrap_or(false) {
2213 files += 1;
2214 if let Ok(body) = std::fs::read_to_string(&path) {
2215 events += body.lines().filter(|l| !l.trim().is_empty()).count();
2216 }
2217 }
2218 }
2219 Ok(json!({"files": files, "events": events}))
2220}
2221
2222fn responder_status_allowed(status: &str) -> bool {
2225 matches!(
2226 status,
2227 "online" | "offline" | "oauth_locked" | "rate_limited" | "degraded"
2228 )
2229}
2230
2231fn relay_slot_for(peer: Option<&str>) -> Result<(String, String, String, String)> {
2232 let state = config::read_relay_state()?;
2233 let (label, slot_info) = match peer {
2234 Some(peer) => (
2235 peer.to_string(),
2236 state
2237 .get("peers")
2238 .and_then(|p| p.get(peer))
2239 .ok_or_else(|| {
2240 anyhow!(
2241 "unknown peer {peer:?} in relay state — pair with them first:\n \
2242 wire add {peer}@wireup.net (or {peer}@<their-relay>)\n\
2243 (`wire peers` lists who you've already paired with.)"
2244 )
2245 })?,
2246 ),
2247 None => (
2248 "self".to_string(),
2249 state.get("self").filter(|v| !v.is_null()).ok_or_else(|| {
2250 anyhow!("self slot not bound — run `wire bind-relay <url>` first")
2251 })?,
2252 ),
2253 };
2254 let relay_url = slot_info["relay_url"]
2255 .as_str()
2256 .ok_or_else(|| anyhow!("{label} relay_url missing"))?
2257 .to_string();
2258 let slot_id = slot_info["slot_id"]
2259 .as_str()
2260 .ok_or_else(|| anyhow!("{label} slot_id missing"))?
2261 .to_string();
2262 let slot_token = slot_info["slot_token"]
2263 .as_str()
2264 .ok_or_else(|| anyhow!("{label} slot_token missing"))?
2265 .to_string();
2266 Ok((label, relay_url, slot_id, slot_token))
2267}
2268
2269fn cmd_responder_set(status: &str, reason: Option<&str>, as_json: bool) -> Result<()> {
2270 if !responder_status_allowed(status) {
2271 bail!("status must be one of: online, offline, oauth_locked, rate_limited, degraded");
2272 }
2273 let (_label, relay_url, slot_id, slot_token) = relay_slot_for(None)?;
2274 let now = time::OffsetDateTime::now_utc()
2275 .format(&time::format_description::well_known::Rfc3339)
2276 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
2277 let mut record = json!({
2278 "status": status,
2279 "set_at": now,
2280 });
2281 if let Some(reason) = reason {
2282 record["reason"] = json!(reason);
2283 }
2284 if status == "online" {
2285 record["last_success_at"] = json!(now);
2286 }
2287 let client = crate::relay_client::RelayClient::new(&relay_url);
2288 let saved = client.responder_health_set(&slot_id, &slot_token, &record)?;
2289 if as_json {
2290 println!("{}", serde_json::to_string(&saved)?);
2291 } else {
2292 let reason = saved
2293 .get("reason")
2294 .and_then(Value::as_str)
2295 .map(|r| format!(" — {r}"))
2296 .unwrap_or_default();
2297 println!(
2298 "responder {}{}",
2299 saved
2300 .get("status")
2301 .and_then(Value::as_str)
2302 .unwrap_or(status),
2303 reason
2304 );
2305 }
2306 Ok(())
2307}
2308
2309fn cmd_responder_get(peer: Option<&str>, as_json: bool) -> Result<()> {
2310 let (label, relay_url, slot_id, slot_token) = relay_slot_for(peer)?;
2311 let client = crate::relay_client::RelayClient::new(&relay_url);
2312 let health = client.responder_health_get(&slot_id, &slot_token)?;
2313 if as_json {
2314 println!(
2315 "{}",
2316 serde_json::to_string(&json!({
2317 "target": label,
2318 "responder_health": health,
2319 }))?
2320 );
2321 } else if health.is_null() {
2322 println!("{label}: responder health not reported");
2323 } else {
2324 let status = health
2325 .get("status")
2326 .and_then(Value::as_str)
2327 .unwrap_or("unknown");
2328 let reason = health
2329 .get("reason")
2330 .and_then(Value::as_str)
2331 .map(|r| format!(" — {r}"))
2332 .unwrap_or_default();
2333 let last_success = health
2334 .get("last_success_at")
2335 .and_then(Value::as_str)
2336 .map(|t| format!(" (last_success: {t})"))
2337 .unwrap_or_default();
2338 println!("{label}: {status}{reason}{last_success}");
2339 }
2340 Ok(())
2341}
2342
2343fn cmd_status_peer(peer: &str, as_json: bool) -> Result<()> {
2344 let (_label, relay_url, slot_id, slot_token) = relay_slot_for(Some(peer))?;
2345 let client = crate::relay_client::RelayClient::new(&relay_url);
2346
2347 let started = std::time::Instant::now();
2348 let transport_ok = client.healthz().unwrap_or(false);
2349 let latency_ms = started.elapsed().as_millis() as u64;
2350
2351 let (event_count, last_pull_at_unix) = client.slot_state(&slot_id, &slot_token)?;
2352 let now = std::time::SystemTime::now()
2353 .duration_since(std::time::UNIX_EPOCH)
2354 .map(|d| d.as_secs())
2355 .unwrap_or(0);
2356 let attention = match last_pull_at_unix {
2357 Some(last) if now.saturating_sub(last) <= 300 => json!({
2358 "status": "ok",
2359 "last_pull_at_unix": last,
2360 "age_seconds": now.saturating_sub(last),
2361 "event_count": event_count,
2362 }),
2363 Some(last) => json!({
2364 "status": "stale",
2365 "last_pull_at_unix": last,
2366 "age_seconds": now.saturating_sub(last),
2367 "event_count": event_count,
2368 }),
2369 None => json!({
2370 "status": "never_pulled",
2371 "last_pull_at_unix": Value::Null,
2372 "event_count": event_count,
2373 }),
2374 };
2375
2376 let responder_health = client.responder_health_get(&slot_id, &slot_token)?;
2377 let responder = if responder_health.is_null() {
2378 json!({"status": "not_reported", "record": Value::Null})
2379 } else {
2380 json!({
2381 "status": responder_health
2382 .get("status")
2383 .and_then(Value::as_str)
2384 .unwrap_or("unknown"),
2385 "record": responder_health,
2386 })
2387 };
2388
2389 let report = json!({
2390 "peer": peer,
2391 "transport": {
2392 "status": if transport_ok { "ok" } else { "error" },
2393 "relay_url": relay_url,
2394 "latency_ms": latency_ms,
2395 },
2396 "attention": attention,
2397 "responder": responder,
2398 });
2399
2400 if as_json {
2401 println!("{}", serde_json::to_string(&report)?);
2402 } else {
2403 let transport_line = if transport_ok {
2404 format!("ok relay reachable ({latency_ms}ms)")
2405 } else {
2406 "error relay unreachable".to_string()
2407 };
2408 println!("transport {transport_line}");
2409 match report["attention"]["status"].as_str().unwrap_or("unknown") {
2410 "ok" => println!(
2411 "attention ok last pull {}s ago",
2412 report["attention"]["age_seconds"].as_u64().unwrap_or(0)
2413 ),
2414 "stale" => println!(
2415 "attention stale last pull {}m ago",
2416 report["attention"]["age_seconds"].as_u64().unwrap_or(0) / 60
2417 ),
2418 "never_pulled" => println!("attention never pulled since relay reset"),
2419 other => println!("attention {other}"),
2420 }
2421 if report["responder"]["status"] == "not_reported" {
2422 println!("auto-responder not reported");
2423 } else {
2424 let record = &report["responder"]["record"];
2425 let status = record
2426 .get("status")
2427 .and_then(Value::as_str)
2428 .unwrap_or("unknown");
2429 let reason = record
2430 .get("reason")
2431 .and_then(Value::as_str)
2432 .map(|r| format!(" — {r}"))
2433 .unwrap_or_default();
2434 println!("auto-responder {status}{reason}");
2435 }
2436 }
2437 Ok(())
2438}
2439
2440fn current_cwd_display() -> String {
2448 let cwd = match std::env::current_dir() {
2449 Ok(c) => c,
2450 Err(_) => return String::from("?"),
2451 };
2452 if let Some(home) = dirs::home_dir()
2453 && let Ok(rel) = cwd.strip_prefix(&home)
2454 {
2455 let rel_str = rel.to_string_lossy();
2457 if rel_str.is_empty() {
2458 return String::from("~");
2459 }
2460 return format!("~/{}", rel_str);
2461 }
2462 cwd.to_string_lossy().into_owned()
2463}
2464
2465fn cmd_whoami(as_json: bool, short: bool, colored: bool) -> Result<()> {
2466 if !config::is_initialized()? {
2467 bail!("not initialized — run `wire init <handle>` first");
2468 }
2469 let card = config::read_agent_card()?;
2470 let did = card
2471 .get("did")
2472 .and_then(Value::as_str)
2473 .unwrap_or("")
2474 .to_string();
2475 let handle = card
2476 .get("handle")
2477 .and_then(Value::as_str)
2478 .map(str::to_string)
2479 .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
2480 let character = crate::character::Character::from_did(&did);
2484
2485 let cwd_display = current_cwd_display();
2491
2492 if short {
2495 println!("{} · {}", character.short(), cwd_display);
2496 return Ok(());
2497 }
2498 if colored {
2499 println!("{} \x1b[2m·\x1b[0m {}", character.colored(), cwd_display);
2500 return Ok(());
2501 }
2502
2503 let pk_b64 = card
2504 .get("verify_keys")
2505 .and_then(Value::as_object)
2506 .and_then(|m| m.values().next())
2507 .and_then(|v| v.get("key"))
2508 .and_then(Value::as_str)
2509 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
2510 let pk_bytes = crate::signing::b64decode(pk_b64)?;
2511 let fp = fingerprint(&pk_bytes);
2512 let key_id = make_key_id(&handle, &pk_bytes);
2513 let capabilities = card
2514 .get("capabilities")
2515 .cloned()
2516 .unwrap_or_else(|| json!(["wire/v3.1"]));
2517
2518 if as_json {
2519 let has_override = false;
2523 println!(
2524 "{}",
2525 serde_json::to_string(&json!({
2526 "did": did,
2527 "handle": handle,
2528 "fingerprint": fp,
2529 "key_id": key_id,
2530 "public_key_b64": pk_b64,
2531 "capabilities": capabilities,
2532 "config_dir": config::config_dir()?.to_string_lossy(),
2533 "persona": character,
2534 "persona_override": has_override,
2535 }))?
2536 );
2537 } else {
2538 println!("{}", character.colored());
2539 println!("{did} (ed25519:{key_id})");
2540 println!("fingerprint: {fp}");
2541 println!("capabilities: {capabilities}");
2542 }
2543 Ok(())
2544}
2545
2546fn cmd_identity(cmd: IdentityCommand) -> Result<()> {
2549 match cmd {
2550 IdentityCommand::Show { json } => cmd_whoami(json, !json, false),
2557 IdentityCommand::List { json } => cmd_session_list(json),
2558 IdentityCommand::Publish {
2559 nick,
2560 relay,
2561 public_url,
2562 hidden,
2563 json,
2564 } => cmd_claim(&nick, relay.as_deref(), public_url.as_deref(), hidden, json),
2565 IdentityCommand::Destroy { name, force, json } => cmd_session_destroy(&name, force, json),
2566 IdentityCommand::Create {
2567 name,
2568 anonymous,
2569 local: _,
2570 json,
2571 } => cmd_identity_create(name.as_deref(), anonymous, json),
2572 IdentityCommand::Persist {
2573 name,
2574 as_name,
2575 json,
2576 } => cmd_identity_persist(&name, as_name.as_deref(), json),
2577 IdentityCommand::Demote { name, json } => cmd_identity_demote(&name, json),
2578 }
2579}
2580
2581fn cmd_identity_create(name: Option<&str>, anonymous: bool, as_json: bool) -> Result<()> {
2586 if anonymous {
2587 let rand_suffix = format!("{:08x}", rand::random::<u32>());
2589 let anon_name = name
2590 .map(crate::session::sanitize_name)
2591 .unwrap_or_else(|| format!("anon-{rand_suffix}"));
2592 let anon_root = std::env::temp_dir().join(format!("wire-anon-{rand_suffix}"));
2593 std::fs::create_dir_all(&anon_root)
2594 .with_context(|| format!("creating anon root {anon_root:?}"))?;
2595 let session_home = anon_root.join("sessions").join(&anon_name);
2597 std::fs::create_dir_all(&session_home)?;
2598 let status = run_wire_with_home(&session_home, &["init", &anon_name, "--offline"])?;
2599 if !status.success() {
2600 bail!("anonymous identity init failed: {status}");
2601 }
2602 let marker = anon_root.join("anon-marker.json");
2605 std::fs::write(
2606 &marker,
2607 serde_json::to_vec_pretty(&serde_json::json!({
2608 "name": anon_name,
2609 "session_home": session_home.to_string_lossy(),
2610 "created_at": time::OffsetDateTime::now_utc()
2611 .format(&time::format_description::well_known::Rfc3339)
2612 .unwrap_or_default(),
2613 "kind": "anonymous",
2614 }))?,
2615 )?;
2616 let card = serde_json::from_slice::<Value>(&std::fs::read(
2617 session_home
2618 .join("config")
2619 .join("wire")
2620 .join("agent-card.json"),
2621 )?)?;
2622 let did = card
2623 .get("did")
2624 .and_then(Value::as_str)
2625 .unwrap_or("")
2626 .to_string();
2627 if as_json {
2628 println!(
2629 "{}",
2630 serde_json::to_string(&json!({
2631 "kind": "anonymous",
2632 "name": anon_name,
2633 "did": did,
2634 "session_home": session_home.to_string_lossy(),
2635 "anon_root": anon_root.to_string_lossy(),
2636 }))?
2637 );
2638 } else {
2639 println!("created anonymous identity `{anon_name}` ({did})");
2640 println!(
2641 " session_home: {} (dies on reboot — /tmp)",
2642 session_home.display()
2643 );
2644 println!();
2645 println!("activate in this shell:");
2646 println!(" export WIRE_HOME={}", session_home.display());
2647 println!();
2648 println!("promote to persistent later with:");
2649 println!(" wire identity persist {anon_name}");
2650 }
2651 return Ok(());
2652 }
2653 let name_arg = name.map(|s| s.to_string());
2655 cmd_session_new(
2656 name_arg.as_deref(),
2657 "https://wireup.net",
2658 false,
2659 "http://127.0.0.1:8771",
2660 false,
2661 None,
2662 false,
2663 None,
2664 true, true, as_json,
2667 )
2668}
2669
2670fn cmd_identity_persist(name: &str, as_name: Option<&str>, as_json: bool) -> Result<()> {
2673 let temp = std::env::temp_dir();
2675 let mut found: Option<(std::path::PathBuf, std::path::PathBuf)> = None;
2676 for entry in std::fs::read_dir(&temp)?.flatten() {
2677 let path = entry.path();
2678 if !path
2679 .file_name()
2680 .and_then(|s| s.to_str())
2681 .map(|s| s.starts_with("wire-anon-"))
2682 .unwrap_or(false)
2683 {
2684 continue;
2685 }
2686 let marker = path.join("anon-marker.json");
2687 if let Ok(bytes) = std::fs::read(&marker)
2688 && let Ok(json) = serde_json::from_slice::<Value>(&bytes)
2689 && json.get("name").and_then(Value::as_str) == Some(name)
2690 {
2691 let session_home = json
2692 .get("session_home")
2693 .and_then(Value::as_str)
2694 .map(std::path::PathBuf::from)
2695 .ok_or_else(|| anyhow!("anon-marker {marker:?} missing session_home"))?;
2696 found = Some((path, session_home));
2697 break;
2698 }
2699 }
2700 let (anon_root, anon_session_home) = found.ok_or_else(|| {
2701 anyhow!(
2702 "no anonymous identity named `{name}` found in /tmp/wire-anon-* — \
2703 run `wire identity list` to see available identities"
2704 )
2705 })?;
2706
2707 let new_name = as_name.unwrap_or(name);
2708 let new_session_home = crate::session::session_dir(new_name)?;
2709 if new_session_home.exists() {
2710 bail!(
2711 "target session `{new_name}` already exists at {new_session_home:?} — \
2712 pick a different name with --as <new-name>"
2713 );
2714 }
2715
2716 if let Some(parent) = new_session_home.parent() {
2718 std::fs::create_dir_all(parent)?;
2719 }
2720 std::fs::rename(&anon_session_home, &new_session_home)
2721 .with_context(|| format!("rename {anon_session_home:?} → {new_session_home:?}"))?;
2722
2723 let _ = std::fs::remove_dir_all(&anon_root);
2725
2726 let cwd = std::env::current_dir().unwrap_or_else(|_| new_session_home.clone());
2729 let cwd_key = cwd.to_string_lossy().into_owned();
2730 let new_name_for_reg = new_name.to_string();
2731 if let Err(e) = crate::session::update_registry(|reg| {
2732 reg.by_cwd.insert(cwd_key, new_name_for_reg);
2733 Ok(())
2734 }) {
2735 eprintln!("wire identity persist: failed to update registry: {e:#}");
2736 }
2737
2738 if as_json {
2739 println!(
2740 "{}",
2741 serde_json::to_string(&json!({
2742 "kind": "persisted",
2743 "from_name": name,
2744 "to_name": new_name,
2745 "session_home": new_session_home.to_string_lossy(),
2746 }))?
2747 );
2748 } else {
2749 println!("persisted anonymous identity `{name}` → local session `{new_name}`");
2750 println!(
2751 " session_home: {} (survives reboot)",
2752 new_session_home.display()
2753 );
2754 println!(" registered cwd: {}", cwd.display());
2755 }
2756 Ok(())
2757}
2758
2759fn cmd_identity_demote(name: &str, as_json: bool) -> Result<()> {
2765 let sessions = crate::session::list_sessions()?;
2766 let session = sessions
2767 .iter()
2768 .find(|s| s.name == name)
2769 .ok_or_else(|| anyhow!("no session named `{name}` (run `wire identity list`)"))?;
2770 let relay_state_path = session
2771 .home_dir
2772 .join("config")
2773 .join("wire")
2774 .join("relay.json");
2775 if !relay_state_path.exists() {
2776 bail!("session `{name}` has no relay state — already demoted?");
2777 }
2778 let mut state: Value = serde_json::from_slice(&std::fs::read(&relay_state_path)?)?;
2779 let self_obj = state.get("self").cloned().unwrap_or(Value::Null);
2780 let had_fed = self_obj
2781 .get("relay_url")
2782 .and_then(Value::as_str)
2783 .map(|u| {
2784 u.starts_with("https://") || (u.starts_with("http://") && !u.contains("127.0.0.1"))
2785 })
2786 .unwrap_or(false);
2787 if !had_fed {
2788 if as_json {
2789 println!(
2790 "{}",
2791 serde_json::to_string(
2792 &json!({"name": name, "status": "no-op", "reason": "no federation slot"})
2793 )?
2794 );
2795 } else {
2796 println!("session `{name}` has no federation slot — nothing to demote");
2797 }
2798 return Ok(());
2799 }
2800 if let Some(self_mut) = state
2803 .as_object_mut()
2804 .and_then(|m| m.get_mut("self"))
2805 .and_then(|s| s.as_object_mut())
2806 {
2807 self_mut.remove("relay_url");
2808 self_mut.remove("slot_id");
2809 self_mut.remove("slot_token");
2810 if let Some(eps) = self_mut.get_mut("endpoints").and_then(|e| e.as_array_mut()) {
2811 eps.retain(|ep| ep.get("scope").and_then(Value::as_str) != Some("federation"));
2812 }
2813 }
2814 std::fs::write(&relay_state_path, serde_json::to_vec_pretty(&state)?)?;
2815
2816 if as_json {
2817 println!(
2818 "{}",
2819 serde_json::to_string(
2820 &json!({"name": name, "status": "demoted", "from": "federation", "to": "local"})
2821 )?
2822 );
2823 } else {
2824 println!("demoted `{name}` from federation → local");
2825 println!(" relay slot binding removed; keypair + agent-card retained");
2826 println!(" re-publish with `wire identity publish <nick>`");
2827 }
2828 Ok(())
2829}
2830
2831fn effective_peer_tier(trust: &Value, relay_state: &Value, handle: &str) -> String {
2832 let raw = crate::trust::get_tier(trust, handle);
2833 if raw != "VERIFIED" {
2834 return raw.to_string();
2835 }
2836 let token = relay_state
2837 .get("peers")
2838 .and_then(|p| p.get(handle))
2839 .and_then(|p| p.get("slot_token"))
2840 .and_then(Value::as_str)
2841 .unwrap_or("");
2842 if token.is_empty() {
2843 "PENDING_ACK".to_string()
2844 } else {
2845 raw.to_string()
2846 }
2847}
2848
2849fn cmd_peers(as_json: bool) -> Result<()> {
2850 let trust = config::read_trust()?;
2851 let agents = trust
2852 .get("agents")
2853 .and_then(Value::as_object)
2854 .cloned()
2855 .unwrap_or_default();
2856 let relay_state = config::read_relay_state().unwrap_or_else(|_| json!({"peers": {}}));
2857
2858 let mut self_did: Option<String> = None;
2859 if let Ok(card) = config::read_agent_card() {
2860 self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
2861 }
2862
2863 let mut peers = Vec::new();
2864 for (handle, agent) in agents.iter() {
2865 let did = agent
2866 .get("did")
2867 .and_then(Value::as_str)
2868 .unwrap_or("")
2869 .to_string();
2870 if Some(did.as_str()) == self_did.as_deref() {
2871 continue; }
2873 let tier = effective_peer_tier(&trust, &relay_state, handle);
2874 let capabilities = agent
2875 .get("card")
2876 .and_then(|c| c.get("capabilities"))
2877 .cloned()
2878 .unwrap_or_else(|| json!([]));
2879 let character = if did.is_empty() {
2884 None
2885 } else {
2886 let card_obj = agent.get("card");
2887 Some(match card_obj {
2888 Some(card) => crate::character::Character::from_card(card),
2889 None => crate::character::Character::from_did(&did),
2890 })
2891 };
2892 peers.push(json!({
2893 "handle": handle,
2894 "did": did,
2895 "tier": tier,
2896 "capabilities": capabilities,
2897 "persona": character,
2898 }));
2899 }
2900
2901 if as_json {
2902 println!("{}", serde_json::to_string(&peers)?);
2903 } else if peers.is_empty() {
2904 println!("no peers pinned (run `wire join <code>` to pair)");
2905 } else {
2906 for p in &peers {
2912 let char_json = &p["persona"];
2913 let (colored_char, plain_len): (String, usize) = match char_json {
2914 serde_json::Value::Null => ("?".to_string(), 1),
2915 v => match serde_json::from_value::<crate::character::Character>(v.clone()) {
2916 Ok(c) => {
2917 let plain = c.short().chars().count() + 1; (c.colored(), plain)
2919 }
2920 Err(_) => ("?".to_string(), 1),
2921 },
2922 };
2923 let pad = 22usize.saturating_sub(plain_len);
2924 println!(
2925 "{}{} {:<20} {:<10} {}",
2926 colored_char,
2927 " ".repeat(pad),
2928 p["handle"].as_str().unwrap_or(""),
2929 p["tier"].as_str().unwrap_or(""),
2930 p["did"].as_str().unwrap_or(""),
2931 );
2932 }
2933 }
2934 Ok(())
2935}
2936
2937fn maybe_warn_peer_attentiveness(peer: &str) {
2947 let state = match config::read_relay_state() {
2948 Ok(s) => s,
2949 Err(_) => return,
2950 };
2951 let p = state.get("peers").and_then(|p| p.get(peer));
2952 let slot_id = match p.and_then(|p| p.get("slot_id")).and_then(Value::as_str) {
2953 Some(s) if !s.is_empty() => s,
2954 _ => return,
2955 };
2956 let slot_token = match p.and_then(|p| p.get("slot_token")).and_then(Value::as_str) {
2957 Some(s) if !s.is_empty() => s,
2958 _ => return,
2959 };
2960 let relay_url = match p.and_then(|p| p.get("relay_url")).and_then(Value::as_str) {
2961 Some(s) if !s.is_empty() => s.to_string(),
2962 _ => match state
2963 .get("self")
2964 .and_then(|s| s.get("relay_url"))
2965 .and_then(Value::as_str)
2966 {
2967 Some(s) if !s.is_empty() => s.to_string(),
2968 _ => return,
2969 },
2970 };
2971 let client = crate::relay_client::RelayClient::new(&relay_url);
2972 let (_count, last_pull) = match client.slot_state(slot_id, slot_token) {
2973 Ok(t) => t,
2974 Err(_) => return,
2975 };
2976 let now = std::time::SystemTime::now()
2977 .duration_since(std::time::UNIX_EPOCH)
2978 .map(|d| d.as_secs())
2979 .unwrap_or(0);
2980 match last_pull {
2981 None => {
2982 eprintln!(
2983 "phyllis: {peer}'s line is silent — relay sees no pulls yet. message will queue, but they may not be listening."
2984 );
2985 }
2986 Some(t) if now.saturating_sub(t) > 300 => {
2987 let mins = now.saturating_sub(t) / 60;
2988 eprintln!(
2989 "phyllis: {peer} hasn't picked up in {mins}m — message will queue, but they may be away."
2990 );
2991 }
2992 _ => {}
2993 }
2994}
2995
2996pub(crate) fn parse_deadline_until(input: &str) -> Result<String> {
2997 let trimmed = input.trim();
2998 if time::OffsetDateTime::parse(trimmed, &time::format_description::well_known::Rfc3339).is_ok()
2999 {
3000 return Ok(trimmed.to_string());
3001 }
3002 let (amount, unit) = trimmed.split_at(trimmed.len().saturating_sub(1));
3003 let n: i64 = amount
3004 .parse()
3005 .with_context(|| format!("deadline must be `30m`, `2h`, `1d`, or RFC3339: {input:?}"))?;
3006 if n <= 0 {
3007 bail!("deadline duration must be positive: {input:?}");
3008 }
3009 let duration = match unit {
3010 "m" => time::Duration::minutes(n),
3011 "h" => time::Duration::hours(n),
3012 "d" => time::Duration::days(n),
3013 _ => bail!("deadline must end in m, h, d, or be RFC3339: {input:?}"),
3014 };
3015 Ok((time::OffsetDateTime::now_utc() + duration)
3016 .format(&time::format_description::well_known::Rfc3339)
3017 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()))
3018}
3019
3020fn cmd_send(
3021 peer: &str,
3022 kind: &str,
3023 body_arg: &str,
3024 deadline: Option<&str>,
3025 no_auto_pair: bool,
3029 as_json: bool,
3030) -> Result<()> {
3031 if !config::is_initialized()? {
3032 bail!("not initialized — run `wire init <handle>` first");
3033 }
3034 let peer_in = crate::agent_card::bare_handle(peer).to_string();
3035 let peer = match resolve_peer_handle(&peer_in) {
3042 Ok(Some(resolved)) if resolved != peer_in => {
3043 eprintln!("wire send: resolved nickname `{peer_in}` → peer `{resolved}`");
3044 resolved
3045 }
3046 Ok(Some(canonical)) => canonical, Ok(None) => peer_in, Err(ResolveError::Ambiguous(candidates)) => bail!(
3049 "nickname `{peer_in}` is ambiguous — matches {} pinned peers: {}. \
3050 Disambiguate by passing the peer handle (one of those listed) instead of the nickname.",
3051 candidates.len(),
3052 candidates.join(", ")
3053 ),
3054 Err(ResolveError::NotFound) => peer_in, };
3056
3057 let peer_is_pinned = config::read_relay_state()
3064 .ok()
3065 .and_then(|s| s.get("peers").and_then(Value::as_object).cloned())
3066 .map(|peers| peers.contains_key(&peer))
3067 .unwrap_or(false);
3068 if !peer_is_pinned && let Some(sister_name) = crate::session::resolve_local_sister(&peer) {
3069 if no_auto_pair {
3070 bail!(
3071 "wire send: `{peer}` resolves to local sister `{sister_name}` but is not pinned, \
3072 and --no-auto-pair was passed. Run `wire dial {peer}` first, \
3073 then re-run send."
3074 );
3075 }
3076 eprintln!(
3077 "wire send: `{peer}` not pinned yet — auto-pairing via local-sister `{sister_name}` first. \
3078 Pass --no-auto-pair to refuse implicit dialing."
3079 );
3080 cmd_add_local_sister(&sister_name, true).map_err(|e| {
3081 anyhow!("wire send: auto-pair to local sister `{sister_name}` failed: {e:#}")
3082 })?;
3083 }
3084
3085 let peer = peer.as_str();
3086 let sk_seed = config::read_private_key()?;
3087 let card = config::read_agent_card()?;
3088 let did = card.get("did").and_then(Value::as_str).unwrap_or("");
3089 let handle = crate::agent_card::display_handle_from_did(did).to_string();
3090 let pk_b64 = card
3091 .get("verify_keys")
3092 .and_then(Value::as_object)
3093 .and_then(|m| m.values().next())
3094 .and_then(|v| v.get("key"))
3095 .and_then(Value::as_str)
3096 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
3097 let pk_bytes = crate::signing::b64decode(pk_b64)?;
3098
3099 let body_value: Value = if body_arg == "-" {
3104 use std::io::Read;
3105 let mut raw = String::new();
3106 std::io::stdin()
3107 .read_to_string(&mut raw)
3108 .with_context(|| "reading body from stdin")?;
3109 serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
3112 } else if let Some(path) = body_arg.strip_prefix('@') {
3113 let raw =
3114 std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
3115 serde_json::from_str(&raw).unwrap_or(Value::String(raw))
3116 } else {
3117 Value::String(body_arg.to_string())
3118 };
3119
3120 let kind_id = parse_kind(kind)?;
3121
3122 let now = time::OffsetDateTime::now_utc()
3123 .format(&time::format_description::well_known::Rfc3339)
3124 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
3125
3126 let mut event = json!({
3127 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
3128 "timestamp": now,
3129 "from": did,
3130 "to": format!("did:wire:{peer}"),
3131 "type": kind,
3132 "kind": kind_id,
3133 "body": body_value,
3134 });
3135 if let Some(deadline) = deadline {
3136 event["time_sensitive_until"] = json!(parse_deadline_until(deadline)?);
3137 }
3138 let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)?;
3139 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
3140
3141 maybe_warn_peer_attentiveness(peer);
3146
3147 let line = serde_json::to_vec(&signed)?;
3152 let outbox = config::append_outbox_record(peer, &line)?;
3153
3154 if as_json {
3155 println!(
3156 "{}",
3157 serde_json::to_string(&json!({
3158 "event_id": event_id,
3159 "status": "queued",
3160 "peer": peer,
3161 "outbox": outbox.to_string_lossy(),
3162 }))?
3163 );
3164 } else {
3165 println!(
3166 "queued event {event_id} → {peer} (outbox: {})",
3167 outbox.display()
3168 );
3169 }
3170 Ok(())
3171}
3172
3173fn parse_kind(s: &str) -> Result<u32> {
3174 if let Ok(n) = s.parse::<u32>() {
3175 return Ok(n);
3176 }
3177 for (id, name) in crate::signing::kinds() {
3178 if *name == s {
3179 return Ok(*id);
3180 }
3181 }
3182 Ok(1)
3184}
3185
3186fn cmd_here(as_json: bool) -> Result<()> {
3192 let initialized = config::is_initialized().unwrap_or(false);
3193
3194 let (self_did, self_handle, self_character) = if initialized {
3196 let card = config::read_agent_card().ok();
3197 let did = card
3198 .as_ref()
3199 .and_then(|c| c.get("did").and_then(Value::as_str))
3200 .unwrap_or("")
3201 .to_string();
3202 let handle = if did.is_empty() {
3203 String::new()
3204 } else {
3205 crate::agent_card::display_handle_from_did(&did).to_string()
3206 };
3207 let character = if did.is_empty() {
3208 None
3209 } else {
3210 Some(crate::character::Character::from_did(&did))
3212 };
3213 (did, handle, character)
3214 } else {
3215 (String::new(), String::new(), None)
3216 };
3217
3218 let cwd = std::env::current_dir()
3219 .map(|p| p.to_string_lossy().into_owned())
3220 .unwrap_or_default();
3221 let wire_home = std::env::var("WIRE_HOME").unwrap_or_default();
3222
3223 let mut sisters: Vec<Value> = Vec::new();
3225 if let Ok(listing) = crate::session::list_local_sessions() {
3226 for group in listing.local.values() {
3227 for s in group {
3228 if s.handle.as_deref() == Some(self_handle.as_str()) {
3229 continue; }
3231 let ch = s.did.as_deref().map(crate::character::Character::from_did);
3232 sisters.push(json!({
3233 "session": s.name,
3234 "handle": s.handle,
3235 "persona": ch,
3236 }));
3237 }
3238 }
3239 }
3240
3241 let mut peers: Vec<Value> = Vec::new();
3243 if initialized
3244 && let Ok(trust) = config::read_trust()
3245 && let Some(agents) = trust.get("agents").and_then(Value::as_object)
3246 {
3247 for (handle, agent) in agents {
3248 if handle == &self_handle {
3249 continue; }
3251 let did = agent.get("did").and_then(Value::as_str).unwrap_or("");
3252 let ch = if did.is_empty() {
3253 None
3254 } else {
3255 Some(crate::character::Character::from_did(did))
3256 };
3257 peers.push(json!({
3258 "handle": handle,
3259 "did": did,
3260 "tier": agent.get("tier").and_then(Value::as_str).unwrap_or("UNKNOWN"),
3261 "persona": ch,
3262 }));
3263 }
3264 }
3265
3266 if as_json {
3267 println!(
3268 "{}",
3269 serde_json::to_string(&json!({
3270 "self": {
3271 "handle": self_handle,
3272 "did": self_did,
3273 "persona": self_character,
3274 "cwd": cwd,
3275 "wire_home": wire_home,
3276 },
3277 "sister_sessions": sisters,
3278 "pinned_peers": peers,
3279 }))?
3280 );
3281 return Ok(());
3282 }
3283
3284 if !initialized {
3286 println!("not initialized — run `wire init <handle>` to bootstrap.");
3287 return Ok(());
3288 }
3289 let glyph = self_character
3290 .as_ref()
3291 .map(crate::character::emoji_with_fallback)
3292 .unwrap_or_else(|| "?".to_string());
3293 let nick = self_character
3294 .as_ref()
3295 .map(|c| c.nickname.clone())
3296 .unwrap_or_default();
3297 println!("you are {glyph} {nick} ({self_handle})");
3298 if !cwd.is_empty() {
3299 println!(" cwd: {cwd}");
3300 }
3301 let render_glyph = |character: &Value| -> String {
3306 let emoji = character
3307 .get("emoji")
3308 .and_then(Value::as_str)
3309 .unwrap_or("?");
3310 let nickname = character
3311 .get("nickname")
3312 .and_then(Value::as_str)
3313 .unwrap_or("?");
3314 if crate::character::terminal_supports_emoji() {
3315 return emoji.to_string();
3316 }
3317 let synth = crate::character::Character {
3320 nickname: nickname.to_string(),
3321 emoji: emoji.to_string(),
3322 palette: crate::character::Palette {
3323 primary_hex: String::new(),
3324 accent_hex: String::new(),
3325 ansi256_primary: 0,
3326 ansi256_accent: 0,
3327 },
3328 };
3329 crate::character::emoji_with_fallback(&synth)
3330 };
3331 if !sisters.is_empty() {
3332 println!();
3333 println!("sister sessions on this machine:");
3334 for s in &sisters {
3335 let session = s["session"].as_str().unwrap_or("?");
3336 let ch_nick = s["persona"]["nickname"].as_str().unwrap_or("?");
3337 let glyph = render_glyph(&s["persona"]);
3338 println!(" {glyph} {ch_nick} ({session})");
3339 }
3340 }
3341 if !peers.is_empty() {
3342 println!();
3343 println!("pinned peers:");
3344 for p in &peers {
3345 let handle = p["handle"].as_str().unwrap_or("?");
3346 let tier = p["tier"].as_str().unwrap_or("");
3347 let ch_nick = p["persona"]["nickname"].as_str().unwrap_or("?");
3348 let glyph = render_glyph(&p["persona"]);
3349 println!(" {glyph} {ch_nick} ({handle}) [{tier}]");
3350 }
3351 }
3352 if sisters.is_empty() && peers.is_empty() {
3353 println!();
3354 println!(
3355 "no neighbors yet — `wire session new` to add a sister, or `wire dial <peer>` to reach out."
3356 );
3357 }
3358 Ok(())
3359}
3360
3361fn cmd_dial(name: &str, message: Option<&str>, as_json: bool) -> Result<()> {
3373 if name.contains('@') {
3374 cmd_add(name, None, false, true)
3380 .map_err(|e| anyhow!("wire dial: federation pair to `{name}` failed: {e:#}"))?;
3381 if let Some(msg) = message {
3382 let bare = name.split('@').next().unwrap_or(name);
3384 cmd_send(bare, "claim", msg, None, false, as_json)?;
3385 }
3386 return Ok(());
3387 }
3388
3389 let resolution = match resolve_name_to_target(name) {
3394 Ok(r) => r,
3395 Err(e) if as_json => {
3396 let pool = known_local_names();
3397 let suggestions = closest_candidates(name, &pool, 3, 3);
3398 println!(
3399 "{}",
3400 serde_json::to_string(&json!({
3401 "name_input": name,
3402 "found": false,
3403 "candidates": suggestions,
3404 "error": format!("{e:#}"),
3405 }))?
3406 );
3407 return Ok(());
3408 }
3409 Err(e) => return Err(e),
3410 };
3411 let mut steps: Vec<Value> = Vec::new();
3412
3413 match &resolution {
3414 DialTarget::PinnedPeer { handle, .. } => {
3415 steps.push(json!({
3416 "step": "resolved",
3417 "kind": "already_pinned",
3418 "handle": handle,
3419 }));
3420 }
3421 DialTarget::LocalSister { session_name, .. } => {
3422 steps.push(json!({
3423 "step": "resolved",
3424 "kind": "local_sister",
3425 "session": session_name,
3426 }));
3427 cmd_add_local_sister(session_name, true).map_err(|e| {
3433 anyhow!("dial: local-sister pair to `{session_name}` failed: {e:#}")
3434 })?;
3435 steps.push(json!({
3436 "step": "paired",
3437 "via": "local_sister",
3438 }));
3439 }
3440 }
3441
3442 let send_handle = match &resolution {
3443 DialTarget::PinnedPeer { handle, .. } => handle.clone(),
3444 DialTarget::LocalSister { handle, .. } => handle.clone(),
3445 };
3446
3447 let send_result = if let Some(msg) = message {
3448 let r = cmd_send(&send_handle, "claim", msg, None, false, true);
3449 match &r {
3450 Ok(()) => steps.push(json!({"step": "sent", "to": send_handle, "kind": "claim"})),
3451 Err(e) => steps.push(json!({"step": "send_failed", "error": format!("{e:#}")})),
3452 }
3453 Some(r)
3454 } else {
3455 None
3456 };
3457
3458 if as_json {
3459 println!(
3460 "{}",
3461 serde_json::to_string(&json!({
3462 "name_input": name,
3463 "resolved_handle": send_handle,
3464 "steps": steps,
3465 }))?
3466 );
3467 } else {
3468 println!("wire dial: resolved `{name}` → handle `{send_handle}`");
3469 for s in &steps {
3470 let step = s.get("step").and_then(Value::as_str).unwrap_or("?");
3471 println!(" - {step}");
3472 }
3473 if message.is_some() {
3474 println!(" (use `wire tail {send_handle}` to read replies)");
3475 }
3476 }
3477 if let Some(Err(e)) = send_result {
3478 return Err(e);
3479 }
3480 Ok(())
3481}
3482
3483fn cmd_whois_local(name: &str, as_json: bool) -> Result<()> {
3489 let resolution = match resolve_name_to_target(name) {
3495 Ok(r) => r,
3496 Err(e) if as_json => {
3497 let pool = known_local_names();
3498 let suggestions = closest_candidates(name, &pool, 3, 3);
3499 println!(
3500 "{}",
3501 serde_json::to_string(&json!({
3502 "name_input": name,
3503 "found": false,
3504 "candidates": suggestions,
3505 "error": format!("{e:#}"),
3506 }))?
3507 );
3508 return Ok(());
3509 }
3510 Err(e) => return Err(e),
3511 };
3512 match resolution {
3513 DialTarget::PinnedPeer {
3514 handle,
3515 did,
3516 nickname,
3517 emoji,
3518 tier,
3519 } => {
3520 if as_json {
3521 println!(
3522 "{}",
3523 serde_json::to_string(&json!({
3524 "kind": "pinned_peer",
3525 "handle": handle,
3526 "did": did,
3527 "nickname": nickname,
3528 "emoji": emoji,
3529 "tier": tier,
3530 }))?
3531 );
3532 } else {
3533 let n = nickname.as_deref().unwrap_or("(no character)");
3534 let e = emoji.as_deref().unwrap_or("?");
3535 println!("{e} {n}");
3536 println!(" handle: {handle}");
3537 println!(" did: {did}");
3538 println!(" tier: {tier}");
3539 println!(" reach: pinned peer (already in trust ring + slot pinned)");
3540 }
3541 }
3542 DialTarget::LocalSister {
3543 session_name,
3544 handle,
3545 did,
3546 nickname,
3547 emoji,
3548 } => {
3549 if as_json {
3550 println!(
3551 "{}",
3552 serde_json::to_string(&json!({
3553 "kind": "local_sister",
3554 "session_name": session_name,
3555 "handle": handle,
3556 "did": did,
3557 "nickname": nickname,
3558 "emoji": emoji,
3559 }))?
3560 );
3561 } else {
3562 let n = nickname.as_deref().unwrap_or("(no character)");
3563 let e = emoji.as_deref().unwrap_or("?");
3564 println!("{e} {n}");
3565 println!(" session: {session_name}");
3566 println!(" handle: {handle}");
3567 println!(
3568 " did: {}",
3569 did.as_deref().unwrap_or("(card unreadable)")
3570 );
3571 println!(" reach: local sister on this machine — `wire dial {n}` pairs us");
3572 }
3573 }
3574 }
3575 Ok(())
3576}
3577
3578enum DialTarget {
3579 PinnedPeer {
3580 handle: String,
3581 did: String,
3582 nickname: Option<String>,
3583 emoji: Option<String>,
3584 tier: String,
3585 },
3586 LocalSister {
3587 session_name: String,
3588 handle: String,
3589 did: Option<String>,
3590 nickname: Option<String>,
3591 emoji: Option<String>,
3592 },
3593}
3594
3595fn resolve_name_to_target(name: &str) -> Result<DialTarget> {
3599 let needle = name.trim();
3600 if needle.is_empty() {
3601 bail!("empty name");
3602 }
3603
3604 if config::is_initialized().unwrap_or(false) {
3607 let trust = config::read_trust().unwrap_or(serde_json::Value::Null);
3608 if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
3609 for (handle_key, agent) in agents {
3610 let did = agent.get("did").and_then(Value::as_str).unwrap_or("");
3611 if did.is_empty() {
3612 continue;
3613 }
3614 let handle = handle_key.clone();
3615 let character = crate::character::Character::from_did(did);
3616 let tier = agent
3617 .get("tier")
3618 .and_then(Value::as_str)
3619 .unwrap_or("UNKNOWN")
3620 .to_string();
3621 let matches = handle.eq_ignore_ascii_case(needle)
3622 || did.eq_ignore_ascii_case(needle)
3623 || character.nickname.eq_ignore_ascii_case(needle);
3624 if matches {
3625 return Ok(DialTarget::PinnedPeer {
3626 handle,
3627 did: did.to_string(),
3628 nickname: Some(character.nickname),
3629 emoji: Some(character.emoji.to_string()),
3630 tier,
3631 });
3632 }
3633 }
3634 }
3635 }
3636
3637 if let Some(session_name) = crate::session::resolve_local_sister(needle) {
3639 let sessions = crate::session::list_sessions().unwrap_or_default();
3640 let s = sessions.iter().find(|s| s.name == session_name);
3641 if let Some(s) = s {
3642 return Ok(DialTarget::LocalSister {
3643 session_name: s.name.clone(),
3644 handle: s.handle.clone().unwrap_or_else(|| s.name.clone()),
3645 did: s.did.clone(),
3646 nickname: s.character.as_ref().map(|c| c.nickname.clone()),
3647 emoji: s.character.as_ref().map(|c| c.emoji.to_string()),
3648 });
3649 }
3650 }
3651
3652 let pool = known_local_names();
3657 let suggestions = closest_candidates(name, &pool, 3, 3);
3658 if suggestions.is_empty() {
3659 bail!(
3660 "no peer matched `{name}`.\n\
3661 Tried: pinned peers (`wire peers`) + local sister sessions \
3662 (`wire session list-local`).\n\
3663 For cross-machine federation: `wire dial <handle>@<relay-domain>`."
3664 );
3665 }
3666 bail!(
3667 "no peer matched `{name}`.\n\
3668 Did you mean: {}?\n\
3669 List all: `wire peers`, `wire session list-local`.",
3670 suggestions
3671 .iter()
3672 .map(|s| format!("`{s}`"))
3673 .collect::<Vec<_>>()
3674 .join(", ")
3675 );
3676}
3677
3678fn cmd_tail(peer: Option<&str>, as_json: bool, limit: usize) -> Result<()> {
3681 let inbox = config::inbox_dir()?;
3682 if !inbox.exists() {
3683 if !as_json {
3684 eprintln!("no inbox yet — daemon hasn't run, or no events received");
3685 }
3686 return Ok(());
3687 }
3688 let trust = config::read_trust()?;
3689 let mut count = 0usize;
3690
3691 let entries: Vec<_> = std::fs::read_dir(&inbox)?
3692 .filter_map(|e| e.ok())
3693 .map(|e| e.path())
3694 .filter(|p| {
3695 p.extension().map(|x| x == "jsonl").unwrap_or(false)
3696 && match peer {
3697 Some(want) => p.file_stem().and_then(|s| s.to_str()) == Some(want),
3698 None => true,
3699 }
3700 })
3701 .collect();
3702
3703 for path in entries {
3704 let body = std::fs::read_to_string(&path)?;
3705 for line in body.lines() {
3706 let event: Value = match serde_json::from_str(line) {
3707 Ok(v) => v,
3708 Err(_) => continue,
3709 };
3710 let verified = verify_message_v31(&event, &trust).is_ok();
3711 if as_json {
3712 let mut event_with_meta = event.clone();
3713 if let Some(obj) = event_with_meta.as_object_mut() {
3714 obj.insert("verified".into(), json!(verified));
3715 }
3716 println!("{}", serde_json::to_string(&event_with_meta)?);
3717 } else {
3718 let ts = event
3719 .get("timestamp")
3720 .and_then(Value::as_str)
3721 .unwrap_or("?");
3722 let from = event.get("from").and_then(Value::as_str).unwrap_or("?");
3723 let kind = event.get("kind").and_then(Value::as_u64).unwrap_or(0);
3724 let kind_name = event.get("type").and_then(Value::as_str).unwrap_or("?");
3725 let summary = event
3726 .get("body")
3727 .map(|b| match b {
3728 Value::String(s) => s.clone(),
3729 _ => b.to_string(),
3730 })
3731 .unwrap_or_default();
3732 let mark = if verified { "✓" } else { "✗" };
3733 let deadline = event
3734 .get("time_sensitive_until")
3735 .and_then(Value::as_str)
3736 .map(|d| format!(" deadline: {d}"))
3737 .unwrap_or_default();
3738 println!("[{ts} {from} kind={kind} {kind_name}{deadline}] {summary} | sig {mark}");
3739 }
3740 count += 1;
3741 if limit > 0 && count >= limit {
3742 return Ok(());
3743 }
3744 }
3745 }
3746 Ok(())
3747}
3748
3749fn monitor_is_noise_kind(kind: &str) -> bool {
3755 matches!(kind, "pair_drop" | "pair_drop_ack" | "heartbeat")
3756}
3757
3758fn resolve_persona(peer_handle: &str) -> Option<crate::character::Character> {
3762 let trust = config::read_trust().ok()?;
3763 let agent = trust.get("agents").and_then(|a| a.get(peer_handle))?;
3764 if let Some(card) = agent.get("card") {
3765 Some(crate::character::Character::from_card(card))
3766 } else {
3767 let did = agent.get("did").and_then(Value::as_str)?;
3768 Some(crate::character::Character::from_did(did))
3769 }
3770}
3771
3772fn persona_label(peer_handle: &str) -> String {
3774 match resolve_persona(peer_handle) {
3775 Some(ch) => format!("{} {}", ch.emoji, ch.nickname),
3776 None => peer_handle.to_string(),
3777 }
3778}
3779
3780fn monitor_render(e: &crate::inbox_watch::InboxEvent, as_json: bool) -> Result<String> {
3788 if as_json {
3789 Ok(serde_json::to_string(e)?)
3790 } else {
3791 let eid_short: String = e.event_id.chars().take(12).collect();
3792 let body = e.body_preview.replace('\n', " ");
3793 let ts: String = e.timestamp.chars().take(19).collect();
3794 Ok(format!("[{ts}] {}/{} ({eid_short}) {body}", e.peer, e.kind))
3795 }
3796}
3797
3798fn cmd_monitor(
3814 peer_filter: Option<&str>,
3815 as_json: bool,
3816 include_handshake: bool,
3817 interval_ms: u64,
3818 replay: usize,
3819) -> Result<()> {
3820 let inbox_dir = config::inbox_dir()?;
3821 if !inbox_dir.exists() && !as_json {
3822 eprintln!("wire monitor: inbox dir {inbox_dir:?} missing — has the daemon ever run?");
3823 }
3824 if replay > 0 && inbox_dir.exists() {
3830 let mut all: Vec<crate::inbox_watch::InboxEvent> = Vec::new();
3831 for entry in std::fs::read_dir(&inbox_dir)?.flatten() {
3832 let path = entry.path();
3833 if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
3834 continue;
3835 }
3836 let peer = match path.file_stem().and_then(|s| s.to_str()) {
3837 Some(s) => s.to_string(),
3838 None => continue,
3839 };
3840 if let Some(filter) = peer_filter
3841 && peer != filter
3842 {
3843 continue;
3844 }
3845 let body = std::fs::read_to_string(&path).unwrap_or_default();
3846 for line in body.lines() {
3847 let line = line.trim();
3848 if line.is_empty() {
3849 continue;
3850 }
3851 let signed: Value = match serde_json::from_str(line) {
3852 Ok(v) => v,
3853 Err(_) => continue,
3854 };
3855 let ev = crate::inbox_watch::InboxEvent::from_signed(
3856 &peer, signed, true,
3857 );
3858 if !include_handshake && monitor_is_noise_kind(&ev.kind) {
3859 continue;
3860 }
3861 all.push(ev);
3862 }
3863 }
3864 all.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
3867 let start = all.len().saturating_sub(replay);
3868 for ev in &all[start..] {
3869 println!("{}", monitor_render(ev, as_json)?);
3870 }
3871 use std::io::Write;
3872 std::io::stdout().flush().ok();
3873 }
3874
3875 let mut w = crate::inbox_watch::InboxWatcher::from_head()?;
3878 let sleep_dur = std::time::Duration::from_millis(interval_ms.max(50));
3879
3880 loop {
3881 let events = w.poll()?;
3882 let mut wrote = false;
3883 for ev in events {
3884 if let Some(filter) = peer_filter
3885 && ev.peer != filter
3886 {
3887 continue;
3888 }
3889 if !include_handshake && monitor_is_noise_kind(&ev.kind) {
3890 continue;
3891 }
3892 println!("{}", monitor_render(&ev, as_json)?);
3893 wrote = true;
3894 }
3895 if wrote {
3896 use std::io::Write;
3897 std::io::stdout().flush().ok();
3898 }
3899 std::thread::sleep(sleep_dur);
3900 }
3901}
3902
3903#[cfg(test)]
3904mod tier_tests {
3905 use super::*;
3906 use serde_json::json;
3907
3908 fn trust_with(handle: &str, tier: &str) -> Value {
3909 json!({
3910 "version": 1,
3911 "agents": {
3912 handle: {
3913 "tier": tier,
3914 "did": format!("did:wire:{handle}"),
3915 "card": {"capabilities": ["wire/v3.1"]}
3916 }
3917 }
3918 })
3919 }
3920
3921 #[test]
3922 fn pending_ack_when_verified_but_no_slot_token() {
3923 let trust = trust_with("willard", "VERIFIED");
3927 let relay_state = json!({
3928 "peers": {
3929 "willard": {
3930 "relay_url": "https://relay",
3931 "slot_id": "abc",
3932 "slot_token": "",
3933 }
3934 }
3935 });
3936 assert_eq!(
3937 effective_peer_tier(&trust, &relay_state, "willard"),
3938 "PENDING_ACK"
3939 );
3940 }
3941
3942 #[test]
3943 fn verified_when_slot_token_present() {
3944 let trust = trust_with("willard", "VERIFIED");
3945 let relay_state = json!({
3946 "peers": {
3947 "willard": {
3948 "relay_url": "https://relay",
3949 "slot_id": "abc",
3950 "slot_token": "tok123",
3951 }
3952 }
3953 });
3954 assert_eq!(
3955 effective_peer_tier(&trust, &relay_state, "willard"),
3956 "VERIFIED"
3957 );
3958 }
3959
3960 #[test]
3961 fn raw_tier_passes_through_for_non_verified() {
3962 let trust = trust_with("willard", "UNTRUSTED");
3965 let relay_state = json!({
3966 "peers": {"willard": {"slot_token": ""}}
3967 });
3968 assert_eq!(
3969 effective_peer_tier(&trust, &relay_state, "willard"),
3970 "UNTRUSTED"
3971 );
3972 }
3973
3974 #[test]
3975 fn pending_ack_when_relay_state_missing_peer() {
3976 let trust = trust_with("willard", "VERIFIED");
3980 let relay_state = json!({"peers": {}});
3981 assert_eq!(
3982 effective_peer_tier(&trust, &relay_state, "willard"),
3983 "PENDING_ACK"
3984 );
3985 }
3986}
3987
3988#[cfg(test)]
3989mod monitor_tests {
3990 use super::*;
3991 use crate::inbox_watch::InboxEvent;
3992 use serde_json::Value;
3993
3994 fn ev(peer: &str, kind: &str, body: &str) -> InboxEvent {
3995 InboxEvent {
3996 peer: peer.to_string(),
3997 event_id: "abcd1234567890ef".to_string(),
3998 kind: kind.to_string(),
3999 body_preview: body.to_string(),
4000 verified: true,
4001 timestamp: "2026-05-15T23:14:07.123456Z".to_string(),
4002 raw: Value::Null,
4003 }
4004 }
4005
4006 #[test]
4007 fn monitor_filter_drops_handshake_kinds_by_default() {
4008 assert!(monitor_is_noise_kind("pair_drop"));
4013 assert!(monitor_is_noise_kind("pair_drop_ack"));
4014 assert!(monitor_is_noise_kind("heartbeat"));
4015
4016 assert!(!monitor_is_noise_kind("claim"));
4018 assert!(!monitor_is_noise_kind("decision"));
4019 assert!(!monitor_is_noise_kind("ack"));
4020 assert!(!monitor_is_noise_kind("request"));
4021 assert!(!monitor_is_noise_kind("note"));
4022 assert!(!monitor_is_noise_kind("future_kind_we_dont_know"));
4026 }
4027
4028 #[test]
4029 fn monitor_render_plain_is_one_short_line() {
4030 let e = ev("willard", "claim", "real v8 train shipped 1350 steps");
4031 let line = monitor_render(&e, false).unwrap();
4032 assert!(!line.contains('\n'), "render must be one line: {line}");
4034 assert!(line.contains("willard"));
4036 assert!(line.contains("claim"));
4037 assert!(line.contains("real v8 train"));
4038 assert!(line.contains("abcd12345678"));
4040 assert!(
4041 !line.contains("abcd1234567890ef"),
4042 "should truncate full id"
4043 );
4044 assert!(line.contains("2026-05-15T23:14:07"));
4046 }
4047
4048 #[test]
4049 fn monitor_render_strips_newlines_from_body() {
4050 let e = ev("spark", "claim", "line one\nline two\nline three");
4055 let line = monitor_render(&e, false).unwrap();
4056 assert!(!line.contains('\n'), "newlines must be stripped: {line}");
4057 assert!(line.contains("line one line two line three"));
4058 }
4059
4060 #[test]
4061 fn monitor_render_json_is_valid_jsonl() {
4062 let e = ev("spark", "claim", "hi");
4063 let line = monitor_render(&e, true).unwrap();
4064 assert!(!line.contains('\n'));
4065 let parsed: Value = serde_json::from_str(&line).expect("valid JSONL");
4066 assert_eq!(parsed["peer"], "spark");
4067 assert_eq!(parsed["kind"], "claim");
4068 assert_eq!(parsed["body_preview"], "hi");
4069 }
4070
4071 #[test]
4072 fn monitor_does_not_drop_on_verified_null() {
4073 let mut e = ev("spark", "claim", "from disk with verified=null");
4084 e.verified = false; let line = monitor_render(&e, false).unwrap();
4086 assert!(line.contains("from disk with verified=null"));
4087 assert!(!monitor_is_noise_kind("claim"));
4089 }
4090}
4091
4092fn cmd_verify(path: &str, as_json: bool) -> Result<()> {
4095 let body = if path == "-" {
4096 let mut buf = String::new();
4097 use std::io::Read;
4098 std::io::stdin().read_to_string(&mut buf)?;
4099 buf
4100 } else {
4101 std::fs::read_to_string(path).with_context(|| format!("reading {path}"))?
4102 };
4103 let event: Value = serde_json::from_str(&body)?;
4104 let trust = config::read_trust()?;
4105 match verify_message_v31(&event, &trust) {
4106 Ok(()) => {
4107 if as_json {
4108 println!("{}", serde_json::to_string(&json!({"verified": true}))?);
4109 } else {
4110 println!("verified ✓");
4111 }
4112 Ok(())
4113 }
4114 Err(e) => {
4115 let reason = e.to_string();
4116 if as_json {
4117 println!(
4118 "{}",
4119 serde_json::to_string(&json!({"verified": false, "reason": reason}))?
4120 );
4121 } else {
4122 eprintln!("FAILED: {reason}");
4123 }
4124 std::process::exit(1);
4125 }
4126 }
4127}
4128
4129fn cmd_mcp() -> Result<()> {
4132 crate::mcp::run()
4133}
4134
4135fn cmd_relay_server(bind: &str, local_only: bool, uds: Option<&std::path::Path>) -> Result<()> {
4136 if let Some(socket_path) = uds {
4141 let base = if let Ok(home) = std::env::var("WIRE_HOME") {
4142 std::path::PathBuf::from(home)
4143 .join("state")
4144 .join("wire-relay")
4145 .join("uds")
4146 } else {
4147 dirs::state_dir()
4148 .or_else(dirs::data_local_dir)
4149 .ok_or_else(|| anyhow::anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?
4150 .join("wire-relay")
4151 .join("uds")
4152 };
4153 let runtime = tokio::runtime::Builder::new_multi_thread()
4154 .enable_all()
4155 .build()?;
4156 return runtime.block_on(crate::relay_server::serve_uds(
4157 socket_path.to_path_buf(),
4158 base,
4159 ));
4160 }
4161 if local_only {
4165 validate_loopback_bind(bind)?;
4166 }
4167 let base = if let Ok(home) = std::env::var("WIRE_HOME") {
4173 std::path::PathBuf::from(home)
4174 .join("state")
4175 .join("wire-relay")
4176 } else {
4177 dirs::state_dir()
4178 .or_else(dirs::data_local_dir)
4179 .ok_or_else(|| anyhow::anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?
4180 .join("wire-relay")
4181 };
4182 let state_dir = if local_only { base.join("local") } else { base };
4183 let runtime = tokio::runtime::Builder::new_multi_thread()
4184 .enable_all()
4185 .build()?;
4186 runtime.block_on(crate::relay_server::serve_with_mode(
4187 bind,
4188 state_dir,
4189 crate::relay_server::ServerMode { local_only },
4190 ))
4191}
4192
4193fn validate_loopback_bind(bind: &str) -> Result<()> {
4211 let host = if let Some(stripped) = bind.strip_prefix('[') {
4213 let close = stripped
4214 .find(']')
4215 .ok_or_else(|| anyhow::anyhow!("malformed IPv6 bind {bind:?}"))?;
4216 stripped[..close].to_string()
4217 } else {
4218 bind.rsplit_once(':')
4219 .map(|(h, _)| h.to_string())
4220 .unwrap_or_else(|| bind.to_string())
4221 };
4222 use std::net::{IpAddr, ToSocketAddrs};
4223 let probe = format!("{host}:0");
4224 let resolved: Vec<_> = probe
4225 .to_socket_addrs()
4226 .with_context(|| format!("resolving bind host {host:?}"))?
4227 .collect();
4228 if resolved.is_empty() {
4229 bail!("--local-only: bind host {host:?} resolved to no addresses");
4230 }
4231 for addr in &resolved {
4232 let ip = addr.ip();
4233 let is_acceptable = match ip {
4234 IpAddr::V4(v4) => {
4235 v4.is_loopback() || v4.is_private() || {
4236 let octets = v4.octets();
4238 octets[0] == 100 && (64..=127).contains(&octets[1])
4239 }
4240 }
4241 IpAddr::V6(v6) => v6.is_loopback(), };
4243 if !is_acceptable {
4244 bail!(
4245 "--local-only refuses non-private bind: {host:?} resolves to {} \
4246 which is not loopback (127/8, ::1), RFC 1918 private \
4247 (10/8, 172.16/12, 192.168/16), or RFC 6598 CGNAT/Tailscale \
4248 (100.64.0.0/10). Remove --local-only to bind publicly.",
4249 ip
4250 );
4251 }
4252 }
4253 Ok(())
4254}
4255
4256fn parse_scope(s: &str) -> Result<crate::endpoints::EndpointScope> {
4259 use crate::endpoints::EndpointScope;
4260 match s.to_lowercase().as_str() {
4261 "federation" | "fed" => Ok(EndpointScope::Federation),
4262 "local" => Ok(EndpointScope::Local),
4263 "lan" => Ok(EndpointScope::Lan),
4264 "uds" => Ok(EndpointScope::Uds),
4265 other => bail!("unknown --scope `{other}` (expected federation|local|lan|uds)"),
4266 }
4267}
4268
4269fn cmd_bind_relay(
4275 url: &str,
4276 scope: Option<&str>,
4277 replace: bool,
4278 migrate_pinned: bool,
4279 as_json: bool,
4280) -> Result<()> {
4281 use crate::endpoints::{self_endpoints, Endpoint};
4282
4283 if !config::is_initialized()? {
4284 bail!("not initialized — run `wire init <handle>` first");
4285 }
4286 let card = config::read_agent_card()?;
4287 let did = card.get("did").and_then(Value::as_str).unwrap_or("");
4288 let handle = crate::agent_card::display_handle_from_did(did).to_string();
4289
4290 let normalized = url.trim_end_matches('/');
4291 let new_scope = match scope {
4292 Some(s) => parse_scope(s)?,
4293 None => crate::endpoints::infer_scope_from_url(normalized),
4294 };
4295
4296 let existing = config::read_relay_state().unwrap_or_else(|_| json!({}));
4297 let pinned: Vec<String> = existing
4298 .get("peers")
4299 .and_then(|p| p.as_object())
4300 .map(|o| o.keys().cloned().collect())
4301 .unwrap_or_default();
4302
4303 let existing_eps = self_endpoints(&existing);
4304 let is_rebind_same = existing_eps.iter().any(|e| e.relay_url == normalized);
4305
4306 let destructive = replace || is_rebind_same;
4313 if destructive && !pinned.is_empty() && !migrate_pinned {
4314 let list = pinned.join(", ");
4315 let why = if replace {
4316 "`--replace` drops your other slot(s)"
4317 } else {
4318 "re-binding the same relay rotates its slot"
4319 };
4320 bail!(
4321 "bind-relay would black-hole {n} pinned peer(s): {list}. {why}; they are \
4322 pinned to your CURRENT slot and would keep pushing to a slot you no longer \
4323 read.\n\n\
4324 SAFE PATHS:\n\
4325 • Default (omit `--replace`) ADDITIVELY binds a NEW relay, keeping existing \
4326 slots — no black-hole.\n\
4327 • `wire rotate-slot` — same-relay rotation that emits wire_close to peers.\n\
4328 • `wire bind-relay {url} --migrate-pinned` — proceed anyway; re-pair each \
4329 peer out-of-band.\n\n\
4330 Issue #7 (silent black-hole on relay change) caught this.",
4331 n = pinned.len(),
4332 );
4333 }
4334
4335 let client = crate::relay_client::RelayClient::new(normalized);
4336 client.check_healthz()?;
4337 let alloc = client.allocate_slot(Some(&handle))?;
4338
4339 if destructive && !pinned.is_empty() {
4340 eprintln!(
4341 "wire bind-relay: {mode} with {n} pinned peer(s) — they will black-hole \
4342 until they re-pin: {peers}",
4343 mode = if replace { "replacing" } else { "rotating" },
4344 n = pinned.len(),
4345 peers = pinned.join(", "),
4346 );
4347 }
4348
4349 let mut state = existing;
4353 if replace {
4354 state["self"] = Value::Null;
4355 }
4356 crate::endpoints::upsert_self_endpoint(
4357 &mut state,
4358 Endpoint {
4359 relay_url: normalized.to_string(),
4360 slot_id: alloc.slot_id.clone(),
4361 slot_token: alloc.slot_token.clone(),
4362 scope: new_scope,
4363 },
4364 );
4365 config::write_relay_state(&state)?;
4366 let eps = self_endpoints(&state);
4367
4368 let scope_str = format!("{new_scope:?}").to_lowercase();
4369 if as_json {
4370 println!(
4371 "{}",
4372 serde_json::to_string(&json!({
4373 "relay_url": normalized,
4374 "slot_id": alloc.slot_id,
4375 "scope": scope_str,
4376 "endpoints": eps.len(),
4377 "additive": !replace,
4378 "slot_token_present": true,
4379 }))?
4380 );
4381 } else {
4382 println!("bound {scope_str} slot on {normalized} (slot {})", alloc.slot_id);
4383 println!(
4384 "self now has {n} endpoint(s): {list}",
4385 n = eps.len(),
4386 list = eps
4387 .iter()
4388 .map(|e| format!("{}({:?})", e.relay_url, e.scope))
4389 .collect::<Vec<_>>()
4390 .join(", "),
4391 );
4392 }
4393 Ok(())
4394}
4395
4396fn cmd_add_peer_slot(
4399 handle: &str,
4400 url: &str,
4401 slot_id: &str,
4402 slot_token: &str,
4403 as_json: bool,
4404) -> Result<()> {
4405 let mut state = config::read_relay_state()?;
4406 let peers = state["peers"]
4407 .as_object_mut()
4408 .ok_or_else(|| anyhow!("relay state missing 'peers' object"))?;
4409 peers.insert(
4410 handle.to_string(),
4411 json!({
4412 "relay_url": url,
4413 "slot_id": slot_id,
4414 "slot_token": slot_token,
4415 }),
4416 );
4417 config::write_relay_state(&state)?;
4418 if as_json {
4419 println!(
4420 "{}",
4421 serde_json::to_string(&json!({
4422 "handle": handle,
4423 "relay_url": url,
4424 "slot_id": slot_id,
4425 "added": true,
4426 }))?
4427 );
4428 } else {
4429 println!("pinned peer slot for {handle} at {url} ({slot_id})");
4430 }
4431 Ok(())
4432}
4433
4434fn cmd_push(peer_filter: Option<&str>, as_json: bool) -> Result<()> {
4437 let state = config::read_relay_state()?;
4438 let peers = state["peers"].as_object().cloned().unwrap_or_default();
4439 if peers.is_empty() {
4440 bail!(
4441 "no peer slots pinned — run `wire add-peer-slot <handle> <url> <slot_id> <token>` first"
4442 );
4443 }
4444 let outbox_dir = config::outbox_dir()?;
4445 if outbox_dir.exists() {
4450 let pinned: std::collections::HashSet<String> = peers.keys().cloned().collect();
4451 for entry in std::fs::read_dir(&outbox_dir)?.flatten() {
4452 let path = entry.path();
4453 if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
4454 continue;
4455 }
4456 let stem = match path.file_stem().and_then(|s| s.to_str()) {
4457 Some(s) => s.to_string(),
4458 None => continue,
4459 };
4460 if pinned.contains(&stem) {
4461 continue;
4462 }
4463 let bare = crate::agent_card::bare_handle(&stem);
4466 if pinned.contains(bare) {
4467 eprintln!(
4468 "wire push: WARN stale outbox file `{}.jsonl` not enumerated (pinned peer is `{bare}`). \
4469 Merge with: `cat {} >> {}` then delete the FQDN file.",
4470 stem,
4471 path.display(),
4472 outbox_dir.join(format!("{bare}.jsonl")).display(),
4473 );
4474 }
4475 }
4476 }
4477 if !outbox_dir.exists() {
4478 if as_json {
4479 println!(
4480 "{}",
4481 serde_json::to_string(&json!({"pushed": [], "skipped": []}))?
4482 );
4483 } else {
4484 println!("phyllis: nothing to dial out — write a message first with `wire send`");
4485 }
4486 return Ok(());
4487 }
4488
4489 let mut pushed = Vec::new();
4490 let mut skipped = Vec::new();
4491
4492 for (peer_handle, _) in peers.iter() {
4498 if let Some(want) = peer_filter
4499 && peer_handle != want
4500 {
4501 continue;
4502 }
4503 let outbox = outbox_dir.join(format!("{peer_handle}.jsonl"));
4504 if !outbox.exists() {
4505 continue;
4506 }
4507 let ordered_endpoints =
4508 crate::endpoints::peer_endpoints_in_priority_order(&state, peer_handle);
4509 if ordered_endpoints.is_empty() {
4510 for line in std::fs::read_to_string(&outbox).unwrap_or_default().lines() {
4514 let event: Value = match serde_json::from_str(line) {
4515 Ok(v) => v,
4516 Err(_) => continue,
4517 };
4518 let event_id = event
4519 .get("event_id")
4520 .and_then(Value::as_str)
4521 .unwrap_or("")
4522 .to_string();
4523 skipped.push(json!({
4524 "peer": peer_handle,
4525 "event_id": event_id,
4526 "reason": "no reachable endpoint pinned for peer",
4527 }));
4528 }
4529 continue;
4530 }
4531 let body = std::fs::read_to_string(&outbox)?;
4532 for line in body.lines() {
4533 let event: Value = match serde_json::from_str(line) {
4534 Ok(v) => v,
4535 Err(_) => continue,
4536 };
4537 let event_id = event
4538 .get("event_id")
4539 .and_then(Value::as_str)
4540 .unwrap_or("")
4541 .to_string();
4542
4543 let mut delivered = false;
4544 let mut last_err_reason: Option<String> = None;
4545 for endpoint in &ordered_endpoints {
4546 let client = crate::relay_client::RelayClient::new(&endpoint.relay_url);
4547 match client.post_event(&endpoint.slot_id, &endpoint.slot_token, &event) {
4548 Ok(resp) => {
4549 if resp.status == "duplicate" {
4550 skipped.push(json!({
4551 "peer": peer_handle,
4552 "event_id": event_id,
4553 "reason": "duplicate",
4554 "endpoint": endpoint.relay_url,
4555 "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
4556 }));
4557 } else {
4558 pushed.push(json!({
4559 "peer": peer_handle,
4560 "event_id": event_id,
4561 "endpoint": endpoint.relay_url,
4562 "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
4563 }));
4564 }
4565 delivered = true;
4566 break;
4567 }
4568 Err(e) => {
4569 last_err_reason = Some(crate::relay_client::format_transport_error(&e));
4574 }
4575 }
4576 }
4577 if !delivered {
4578 skipped.push(json!({
4579 "peer": peer_handle,
4580 "event_id": event_id,
4581 "reason": last_err_reason.unwrap_or_else(|| "all endpoints failed".to_string()),
4582 }));
4583 }
4584 }
4585 }
4586
4587 if as_json {
4588 println!(
4589 "{}",
4590 serde_json::to_string(&json!({"pushed": pushed, "skipped": skipped}))?
4591 );
4592 } else {
4593 println!(
4594 "pushed {} event(s); skipped {} ({})",
4595 pushed.len(),
4596 skipped.len(),
4597 if skipped.is_empty() {
4598 "none"
4599 } else {
4600 "see --json for detail"
4601 }
4602 );
4603 }
4604 Ok(())
4605}
4606
4607fn cmd_pull(as_json: bool) -> Result<()> {
4610 let state = config::read_relay_state()?;
4611 let self_state = state.get("self").cloned().unwrap_or(Value::Null);
4612 if self_state.is_null() {
4613 bail!("self slot not bound — run `wire bind-relay <url>` first");
4614 }
4615
4616 let endpoints = crate::endpoints::self_endpoints(&state);
4625 if endpoints.is_empty() {
4626 bail!("self.relay_url / slot_id / slot_token missing in relay_state.json");
4627 }
4628
4629 let inbox_dir = config::inbox_dir()?;
4630 config::ensure_dirs()?;
4631
4632 let mut total_seen = 0usize;
4633 let mut all_written: Vec<Value> = Vec::new();
4634 let mut all_rejected: Vec<Value> = Vec::new();
4635 let mut all_blocked = false;
4636 let mut all_advance_cursor_to: Option<String> = None;
4637
4638 for endpoint in &endpoints {
4639 let cursor_key = endpoint_cursor_key(endpoint.scope);
4640 let last_event_id = self_state
4641 .get(&cursor_key)
4642 .and_then(Value::as_str)
4643 .map(str::to_string);
4644 let client = crate::relay_client::RelayClient::new(&endpoint.relay_url);
4645 let events = match client.list_events(
4646 &endpoint.slot_id,
4647 &endpoint.slot_token,
4648 last_event_id.as_deref(),
4649 Some(1000),
4650 ) {
4651 Ok(ev) => ev,
4652 Err(e) => {
4653 eprintln!(
4657 "wire pull: endpoint {} ({:?}) errored: {}; continuing",
4658 endpoint.relay_url,
4659 endpoint.scope,
4660 crate::relay_client::format_transport_error(&e),
4661 );
4662 continue;
4663 }
4664 };
4665 total_seen += events.len();
4666 let result = crate::pull::process_events(&events, last_event_id.clone(), &inbox_dir)?;
4667 all_written.extend(result.written.iter().cloned());
4668 all_rejected.extend(result.rejected.iter().cloned());
4669 if result.blocked {
4670 all_blocked = true;
4671 }
4672 if let Some(eid) = result.advance_cursor_to.clone() {
4675 if endpoint.scope == crate::endpoints::EndpointScope::Federation {
4676 all_advance_cursor_to = Some(eid.clone());
4677 }
4678 let key = cursor_key.clone();
4679 config::update_relay_state(|state| {
4680 if let Some(self_obj) = state.get_mut("self").and_then(Value::as_object_mut) {
4681 self_obj.insert(key, Value::String(eid));
4682 }
4683 Ok(())
4684 })?;
4685 }
4686 }
4687
4688 let result = crate::pull::PullResult {
4693 written: all_written,
4694 rejected: all_rejected,
4695 blocked: all_blocked,
4696 advance_cursor_to: all_advance_cursor_to,
4697 };
4698 let events_len = total_seen;
4699
4700 if as_json {
4704 println!(
4705 "{}",
4706 serde_json::to_string(&json!({
4707 "written": result.written,
4708 "rejected": result.rejected,
4709 "total_seen": events_len,
4710 "cursor_blocked": result.blocked,
4711 "cursor_advanced_to": result.advance_cursor_to,
4712 }))?
4713 );
4714 } else {
4715 let blocking = result
4716 .rejected
4717 .iter()
4718 .filter(|r| r.get("blocks_cursor").and_then(Value::as_bool) == Some(true))
4719 .count();
4720 if blocking > 0 {
4721 println!(
4722 "pulled {} event(s); wrote {}; rejected {} ({} BLOCKING cursor — see `wire pull --json`)",
4723 events_len,
4724 result.written.len(),
4725 result.rejected.len(),
4726 blocking,
4727 );
4728 } else {
4729 println!(
4730 "pulled {} event(s); wrote {}; rejected {}",
4731 events_len,
4732 result.written.len(),
4733 result.rejected.len(),
4734 );
4735 }
4736 }
4737 Ok(())
4738}
4739
4740fn endpoint_cursor_key(scope: crate::endpoints::EndpointScope) -> String {
4745 match scope {
4746 crate::endpoints::EndpointScope::Federation => "last_pulled_event_id".to_string(),
4747 crate::endpoints::EndpointScope::Local => "last_pulled_event_id_local".to_string(),
4748 crate::endpoints::EndpointScope::Lan => "last_pulled_event_id_lan".to_string(),
4749 crate::endpoints::EndpointScope::Uds => "last_pulled_event_id_uds".to_string(),
4750 }
4751}
4752
4753fn cmd_rotate_slot(no_announce: bool, as_json: bool) -> Result<()> {
4756 if !config::is_initialized()? {
4757 bail!("not initialized — run `wire init <handle>` first");
4758 }
4759 let mut state = config::read_relay_state()?;
4760 let self_state = state.get("self").cloned().unwrap_or(Value::Null);
4761 if self_state.is_null() {
4762 bail!("self slot not bound — run `wire bind-relay <url>` first (nothing to rotate)");
4763 }
4764 let primary = crate::endpoints::self_primary_endpoint(&state)
4768 .ok_or_else(|| anyhow!("self has no resolvable inbound endpoint to rotate"))?;
4769 let url = primary.relay_url.clone();
4770 let old_slot_id = primary.slot_id.clone();
4771 let old_slot_token = primary.slot_token.clone();
4772
4773 let card = config::read_agent_card()?;
4775 let did = card
4776 .get("did")
4777 .and_then(Value::as_str)
4778 .unwrap_or("")
4779 .to_string();
4780 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
4781 let pk_b64 = card
4782 .get("verify_keys")
4783 .and_then(Value::as_object)
4784 .and_then(|m| m.values().next())
4785 .and_then(|v| v.get("key"))
4786 .and_then(Value::as_str)
4787 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?
4788 .to_string();
4789 let pk_bytes = crate::signing::b64decode(&pk_b64)?;
4790 let sk_seed = config::read_private_key()?;
4791
4792 let normalized = url.trim_end_matches('/').to_string();
4794 let client = crate::relay_client::RelayClient::new(&normalized);
4795 client
4796 .check_healthz()
4797 .context("aborting rotation; old slot still valid")?;
4798 let alloc = client.allocate_slot(Some(&handle))?;
4799 let new_slot_id = alloc.slot_id.clone();
4800 let new_slot_token = alloc.slot_token.clone();
4801
4802 let mut announced: Vec<String> = Vec::new();
4809 if !no_announce {
4810 let now = time::OffsetDateTime::now_utc()
4811 .format(&time::format_description::well_known::Rfc3339)
4812 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
4813 let body = json!({
4814 "reason": "operator-initiated slot rotation",
4815 "new_relay_url": url,
4816 "new_slot_id": new_slot_id,
4817 });
4821 let peers = state["peers"].as_object().cloned().unwrap_or_default();
4822 for (peer_handle, _peer_info) in peers.iter() {
4823 let event = json!({
4824 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
4825 "timestamp": now.clone(),
4826 "from": did,
4827 "to": format!("did:wire:{peer_handle}"),
4828 "type": "wire_close",
4829 "kind": 1201,
4830 "body": body.clone(),
4831 });
4832 let signed = match sign_message_v31(&event, &sk_seed, &pk_bytes, &handle) {
4833 Ok(s) => s,
4834 Err(e) => {
4835 eprintln!("warn: could not sign wire_close for {peer_handle}: {e}");
4836 continue;
4837 }
4838 };
4839 let peer_info = match state["peers"].get(peer_handle) {
4844 Some(p) => p.clone(),
4845 None => continue,
4846 };
4847 let peer_url = peer_info["relay_url"].as_str().unwrap_or(&url);
4848 let peer_slot_id = peer_info["slot_id"].as_str().unwrap_or("");
4849 let peer_slot_token = peer_info["slot_token"].as_str().unwrap_or("");
4850 if peer_slot_id.is_empty() || peer_slot_token.is_empty() {
4851 continue;
4852 }
4853 let peer_client = if peer_url == url {
4854 client.clone()
4855 } else {
4856 crate::relay_client::RelayClient::new(peer_url)
4857 };
4858 match peer_client.post_event(peer_slot_id, peer_slot_token, &signed) {
4859 Ok(_) => announced.push(peer_handle.clone()),
4860 Err(e) => eprintln!("warn: announce to {peer_handle} failed: {e}"),
4861 }
4862 }
4863 }
4864
4865 state["self"] = json!({
4867 "relay_url": url,
4868 "slot_id": new_slot_id,
4869 "slot_token": new_slot_token,
4870 });
4871 config::write_relay_state(&state)?;
4872
4873 if as_json {
4874 println!(
4875 "{}",
4876 serde_json::to_string(&json!({
4877 "rotated": true,
4878 "old_slot_id": old_slot_id,
4879 "new_slot_id": new_slot_id,
4880 "relay_url": url,
4881 "announced_to": announced,
4882 }))?
4883 );
4884 } else {
4885 println!("rotated slot on {url}");
4886 println!(
4887 " old slot_id: {old_slot_id} (orphaned — abusive bearer-holders lose their leverage)"
4888 );
4889 println!(" new slot_id: {new_slot_id}");
4890 if !announced.is_empty() {
4891 println!(
4892 " announced wire_close (kind=1201) to: {}",
4893 announced.join(", ")
4894 );
4895 }
4896 println!();
4897 println!("next steps:");
4898 println!(" - peers see the wire_close event in their next `wire pull`");
4899 println!(
4900 " - paired peers must re-issue: tell them to run `wire add-peer-slot {handle} {url} {new_slot_id} <new-token>`"
4901 );
4902 println!(" (or full re-pair via `wire pair-host`/`wire join`)");
4903 println!(" - until they do, you'll receive but they won't be able to reach you");
4904 let _ = old_slot_token;
4906 }
4907 Ok(())
4908}
4909
4910fn cmd_forget_peer(handle: &str, purge: bool, as_json: bool) -> Result<()> {
4913 let mut trust = config::read_trust()?;
4914 let mut removed_from_trust = false;
4915 if let Some(agents) = trust.get_mut("agents").and_then(Value::as_object_mut)
4916 && agents.remove(handle).is_some()
4917 {
4918 removed_from_trust = true;
4919 }
4920 config::write_trust(&trust)?;
4921
4922 let mut state = config::read_relay_state()?;
4923 let mut removed_from_relay = false;
4924 if let Some(peers) = state.get_mut("peers").and_then(Value::as_object_mut)
4925 && peers.remove(handle).is_some()
4926 {
4927 removed_from_relay = true;
4928 }
4929 config::write_relay_state(&state)?;
4930
4931 let mut purged: Vec<String> = Vec::new();
4932 if purge {
4933 for dir in [config::inbox_dir()?, config::outbox_dir()?] {
4934 let path = dir.join(format!("{handle}.jsonl"));
4935 if path.exists() {
4936 std::fs::remove_file(&path).with_context(|| format!("removing {path:?}"))?;
4937 purged.push(path.to_string_lossy().into());
4938 }
4939 }
4940 }
4941
4942 if !removed_from_trust && !removed_from_relay {
4943 if as_json {
4944 println!(
4945 "{}",
4946 serde_json::to_string(&json!({
4947 "removed": false,
4948 "reason": format!("peer {handle:?} not pinned"),
4949 }))?
4950 );
4951 } else {
4952 eprintln!("peer {handle:?} not found in trust or relay state — nothing to forget");
4953 }
4954 return Ok(());
4955 }
4956
4957 if as_json {
4958 println!(
4959 "{}",
4960 serde_json::to_string(&json!({
4961 "handle": handle,
4962 "removed_from_trust": removed_from_trust,
4963 "removed_from_relay_state": removed_from_relay,
4964 "purged_files": purged,
4965 }))?
4966 );
4967 } else {
4968 println!("forgot peer {handle:?}");
4969 if removed_from_trust {
4970 println!(" - removed from trust.json");
4971 }
4972 if removed_from_relay {
4973 println!(" - removed from relay.json");
4974 }
4975 if !purged.is_empty() {
4976 for p in &purged {
4977 println!(" - deleted {p}");
4978 }
4979 } else if !purge {
4980 println!(" (inbox/outbox files preserved; pass --purge to delete them)");
4981 }
4982 }
4983 Ok(())
4984}
4985
4986fn cmd_daemon(interval_secs: u64, once: bool, as_json: bool) -> Result<()> {
4989 if !config::is_initialized()? {
4990 bail!("not initialized — run `wire init <handle>` first");
4991 }
4992 let interval = std::time::Duration::from_secs(interval_secs.max(1));
4993
4994 if !as_json {
4995 if once {
4996 eprintln!("wire daemon: single sync cycle, then exit");
4997 } else {
4998 eprintln!("wire daemon: syncing every {interval_secs}s. SIGINT to stop.");
4999 }
5000 }
5001
5002 if let Err(e) = crate::pending_pair::cleanup_on_startup() {
5006 eprintln!("daemon: pending-pair cleanup_on_startup error: {e:#}");
5007 }
5008
5009 let (wake_tx, wake_rx) = std::sync::mpsc::channel::<()>();
5015 if !once {
5016 crate::daemon_stream::spawn_stream_subscriber(wake_tx);
5017 }
5018
5019 loop {
5020 let pushed = run_sync_push().unwrap_or_else(|e| {
5021 eprintln!("daemon: push error: {e:#}");
5022 json!({"pushed": [], "skipped": [{"error": e.to_string()}]})
5023 });
5024 let pulled = run_sync_pull().unwrap_or_else(|e| {
5025 eprintln!("daemon: pull error: {e:#}");
5026 json!({"written": [], "rejected": [], "total_seen": 0, "error": e.to_string()})
5027 });
5028 let pairs = crate::pending_pair::tick().unwrap_or_else(|e| {
5029 eprintln!("daemon: pending-pair tick error: {e:#}");
5030 json!({"transitions": []})
5031 });
5032
5033 if as_json {
5034 println!(
5035 "{}",
5036 serde_json::to_string(&json!({
5037 "ts": time::OffsetDateTime::now_utc()
5038 .format(&time::format_description::well_known::Rfc3339)
5039 .unwrap_or_default(),
5040 "push": pushed,
5041 "pull": pulled,
5042 "pairs": pairs,
5043 }))?
5044 );
5045 } else {
5046 let pushed_n = pushed["pushed"].as_array().map(|a| a.len()).unwrap_or(0);
5047 let written_n = pulled["written"].as_array().map(|a| a.len()).unwrap_or(0);
5048 let rejected_n = pulled["rejected"].as_array().map(|a| a.len()).unwrap_or(0);
5049 let pair_transitions = pairs["transitions"]
5050 .as_array()
5051 .map(|a| a.len())
5052 .unwrap_or(0);
5053 if pushed_n > 0 || written_n > 0 || rejected_n > 0 || pair_transitions > 0 {
5054 eprintln!(
5055 "daemon: pushed={pushed_n} pulled={written_n} rejected={rejected_n} pair-transitions={pair_transitions}"
5056 );
5057 }
5058 if let Some(arr) = pairs["transitions"].as_array() {
5060 for t in arr {
5061 eprintln!(
5062 " pair {} : {} → {}",
5063 t.get("code").and_then(Value::as_str).unwrap_or("?"),
5064 t.get("from").and_then(Value::as_str).unwrap_or("?"),
5065 t.get("to").and_then(Value::as_str).unwrap_or("?")
5066 );
5067 if let Some(sas) = t.get("sas").and_then(Value::as_str)
5068 && t.get("to").and_then(Value::as_str) == Some("sas_ready")
5069 {
5070 eprintln!(" SAS digits: {}-{}", &sas[..3], &sas[3..]);
5071 eprintln!(
5072 " Run: wire pair-confirm {} {}",
5073 t.get("code").and_then(Value::as_str).unwrap_or("?"),
5074 sas
5075 );
5076 }
5077 }
5078 }
5079 }
5080
5081 if once {
5082 return Ok(());
5083 }
5084 let _ = wake_rx.recv_timeout(interval);
5089 while wake_rx.try_recv().is_ok() {}
5090 }
5091}
5092
5093fn run_sync_push() -> Result<Value> {
5096 let state = config::read_relay_state()?;
5097 let peers = state["peers"].as_object().cloned().unwrap_or_default();
5098 if peers.is_empty() {
5099 return Ok(json!({"pushed": [], "skipped": []}));
5100 }
5101 let outbox_dir = config::outbox_dir()?;
5102 if !outbox_dir.exists() {
5103 return Ok(json!({"pushed": [], "skipped": []}));
5104 }
5105 let mut pushed = Vec::new();
5106 let mut skipped = Vec::new();
5107 for (peer_handle, slot_info) in peers.iter() {
5108 let outbox = outbox_dir.join(format!("{peer_handle}.jsonl"));
5109 if !outbox.exists() {
5110 continue;
5111 }
5112 let url = slot_info["relay_url"].as_str().unwrap_or("");
5113 let slot_id = slot_info["slot_id"].as_str().unwrap_or("");
5114 let slot_token = slot_info["slot_token"].as_str().unwrap_or("");
5115 if url.is_empty() || slot_id.is_empty() || slot_token.is_empty() {
5116 continue;
5117 }
5118 let client = crate::relay_client::RelayClient::new(url);
5119 let body = std::fs::read_to_string(&outbox)?;
5120 for line in body.lines() {
5121 let event: Value = match serde_json::from_str(line) {
5122 Ok(v) => v,
5123 Err(_) => continue,
5124 };
5125 let event_id = event
5126 .get("event_id")
5127 .and_then(Value::as_str)
5128 .unwrap_or("")
5129 .to_string();
5130 match client.post_event(slot_id, slot_token, &event) {
5131 Ok(resp) => {
5132 if resp.status == "duplicate" {
5133 skipped.push(json!({"peer": peer_handle, "event_id": event_id, "reason": "duplicate"}));
5134 } else {
5135 pushed.push(json!({"peer": peer_handle, "event_id": event_id}));
5136 }
5137 }
5138 Err(e) => {
5139 let reason = crate::relay_client::format_transport_error(&e);
5143 skipped
5144 .push(json!({"peer": peer_handle, "event_id": event_id, "reason": reason}));
5145 }
5146 }
5147 }
5148 }
5149 Ok(json!({"pushed": pushed, "skipped": skipped}))
5150}
5151
5152fn run_sync_pull() -> Result<Value> {
5160 let state = config::read_relay_state()?;
5161 let self_state = state.get("self").cloned().unwrap_or(Value::Null);
5162 if self_state.is_null() {
5163 return Ok(json!({"written": [], "rejected": [], "total_seen": 0}));
5164 }
5165 let ep = match crate::endpoints::self_primary_endpoint(&state) {
5166 Some(e) => e,
5167 None => return Ok(json!({"written": [], "rejected": [], "total_seen": 0})),
5168 };
5169 let url = ep.relay_url.as_str();
5170 let slot_id = ep.slot_id.as_str();
5171 let slot_token = ep.slot_token.as_str();
5172 let last_event_id = self_state
5173 .get("last_pulled_event_id")
5174 .and_then(Value::as_str)
5175 .map(str::to_string);
5176 if url.is_empty() {
5177 return Ok(json!({"written": [], "rejected": [], "total_seen": 0}));
5178 }
5179 let client = crate::relay_client::RelayClient::new(url);
5180 let events = client.list_events(slot_id, slot_token, last_event_id.as_deref(), Some(1000))?;
5181 let inbox_dir = config::inbox_dir()?;
5182 config::ensure_dirs()?;
5183
5184 let result = crate::pull::process_events(&events, last_event_id, &inbox_dir)?;
5188
5189 if let Some(eid) = &result.advance_cursor_to {
5191 let eid = eid.clone();
5192 config::update_relay_state(|state| {
5193 if let Some(self_obj) = state.get_mut("self").and_then(Value::as_object_mut) {
5194 self_obj.insert("last_pulled_event_id".into(), Value::String(eid));
5195 }
5196 Ok(())
5197 })?;
5198 }
5199
5200 Ok(json!({
5201 "written": result.written,
5202 "rejected": result.rejected,
5203 "total_seen": events.len(),
5204 "cursor_blocked": result.blocked,
5205 "cursor_advanced_to": result.advance_cursor_to,
5206 }))
5207}
5208
5209fn cmd_pin(card_file: &str, as_json: bool) -> Result<()> {
5212 let body =
5213 std::fs::read_to_string(card_file).with_context(|| format!("reading {card_file}"))?;
5214 let card: Value =
5215 serde_json::from_str(&body).with_context(|| format!("parsing {card_file}"))?;
5216 crate::agent_card::verify_agent_card(&card)
5217 .map_err(|e| anyhow!("peer card signature invalid: {e}"))?;
5218
5219 let mut trust = config::read_trust()?;
5220 crate::trust::add_agent_card_pin(&mut trust, &card, Some("VERIFIED"));
5221
5222 let did = card.get("did").and_then(Value::as_str).unwrap_or("");
5223 let handle = crate::agent_card::display_handle_from_did(did).to_string();
5224 config::write_trust(&trust)?;
5225
5226 if as_json {
5227 println!(
5228 "{}",
5229 serde_json::to_string(&json!({
5230 "handle": handle,
5231 "did": did,
5232 "tier": "VERIFIED",
5233 "pinned": true,
5234 }))?
5235 );
5236 } else {
5237 println!("pinned {handle} ({did}) at tier VERIFIED");
5238 }
5239 Ok(())
5240}
5241
5242fn cmd_pair_host(relay_url: &str, auto_yes: bool, timeout_secs: u64) -> Result<()> {
5245 pair_orchestrate(relay_url, None, "host", auto_yes, timeout_secs)
5246}
5247
5248fn cmd_pair_join(
5249 code_phrase: &str,
5250 relay_url: &str,
5251 auto_yes: bool,
5252 timeout_secs: u64,
5253) -> Result<()> {
5254 pair_orchestrate(
5255 relay_url,
5256 Some(code_phrase),
5257 "guest",
5258 auto_yes,
5259 timeout_secs,
5260 )
5261}
5262
5263fn pair_orchestrate(
5269 relay_url: &str,
5270 code_in: Option<&str>,
5271 role: &str,
5272 auto_yes: bool,
5273 timeout_secs: u64,
5274) -> Result<()> {
5275 use crate::pair_session::{pair_session_finalize, pair_session_open, pair_session_try_sas};
5276
5277 let mut s = pair_session_open(role, relay_url, code_in)?;
5278
5279 if role == "host" {
5280 eprintln!();
5281 eprintln!("share this code phrase with your peer:");
5282 eprintln!();
5283 eprintln!(" {}", s.code);
5284 eprintln!();
5285 eprintln!(
5286 "waiting for peer to run `wire pair-join {} --relay {relay_url}` ...",
5287 s.code
5288 );
5289 } else {
5290 eprintln!();
5291 eprintln!("joined pair-slot on {relay_url} — waiting for host's SPAKE2 message ...");
5292 }
5293
5294 const HEARTBEAT_SECS: u64 = 10;
5299 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
5300 let started = std::time::Instant::now();
5301 let mut last_heartbeat = started;
5302 let formatted = loop {
5303 if let Some(sas) = pair_session_try_sas(&mut s)? {
5304 break sas;
5305 }
5306 let now = std::time::Instant::now();
5307 if now >= deadline {
5308 return Err(anyhow!(
5309 "timeout after {timeout_secs}s waiting for peer's SPAKE2 message"
5310 ));
5311 }
5312 if now.duration_since(last_heartbeat).as_secs() >= HEARTBEAT_SECS {
5313 let elapsed = now.duration_since(started).as_secs();
5314 eprintln!(" ... still waiting ({elapsed}s / {timeout_secs}s)");
5315 last_heartbeat = now;
5316 }
5317 std::thread::sleep(std::time::Duration::from_millis(250));
5318 };
5319
5320 eprintln!();
5321 eprintln!("SAS digits (must match peer's terminal):");
5322 eprintln!();
5323 eprintln!(" {formatted}");
5324 eprintln!();
5325
5326 if !auto_yes {
5329 eprint!("does this match your peer's terminal? [y/N]: ");
5330 use std::io::Write;
5331 std::io::stderr().flush().ok();
5332 let mut input = String::new();
5333 std::io::stdin().read_line(&mut input)?;
5334 let trimmed = input.trim().to_lowercase();
5335 if trimmed != "y" && trimmed != "yes" {
5336 bail!("SAS confirmation declined — aborting pairing");
5337 }
5338 }
5339 s.sas_confirmed = true;
5340
5341 let result = pair_session_finalize(&mut s, timeout_secs)?;
5343
5344 let peer_did = result["paired_with"].as_str().unwrap_or("");
5345 let peer_role = if role == "host" { "guest" } else { "host" };
5346 eprintln!("paired with {peer_did} (peer role: {peer_role})");
5347 eprintln!("peer card pinned at tier VERIFIED");
5348 eprintln!(
5349 "peer relay slot saved to {}",
5350 config::relay_state_path()?.display()
5351 );
5352
5353 println!("{}", serde_json::to_string(&result)?);
5354 Ok(())
5355}
5356
5357fn cmd_pair(
5363 handle: &str,
5364 code: Option<&str>,
5365 relay: &str,
5366 auto_yes: bool,
5367 timeout_secs: u64,
5368 no_setup: bool,
5369) -> Result<()> {
5370 let init_result = crate::pair_session::init_self_idempotent(handle, None, None)?;
5373 let did = init_result
5374 .get("did")
5375 .and_then(|v| v.as_str())
5376 .unwrap_or("(unknown)")
5377 .to_string();
5378 let already = init_result
5379 .get("already_initialized")
5380 .and_then(|v| v.as_bool())
5381 .unwrap_or(false);
5382 if already {
5383 println!("(identity {did} already initialized — reusing)");
5384 } else {
5385 println!("initialized {did}");
5386 }
5387 println!();
5388
5389 match code {
5391 None => {
5392 println!("hosting pair on {relay} (no code = host) ...");
5393 cmd_pair_host(relay, auto_yes, timeout_secs)?;
5394 }
5395 Some(c) => {
5396 println!("joining pair with code {c} on {relay} ...");
5397 cmd_pair_join(c, relay, auto_yes, timeout_secs)?;
5398 }
5399 }
5400
5401 if !no_setup {
5403 println!();
5404 println!("registering wire as MCP server in detected client configs ...");
5405 if let Err(e) = cmd_setup(true) {
5406 eprintln!("warn: setup --apply failed: {e}");
5408 eprintln!(" pair succeeded; you can re-run `wire setup --apply` manually.");
5409 }
5410 }
5411
5412 println!();
5413 println!("pair complete. Next steps:");
5414 println!(" wire daemon start # background sync of inbox/outbox vs relay");
5415 println!(" wire send <peer> claim <msg> # send your peer something");
5416 println!(" wire tail # watch incoming events");
5417 Ok(())
5418}
5419
5420fn cmd_pair_detach(handle: &str, code: Option<&str>, relay: &str) -> Result<()> {
5426 let init_result = crate::pair_session::init_self_idempotent(handle, None, None)?;
5427 let did = init_result
5428 .get("did")
5429 .and_then(|v| v.as_str())
5430 .unwrap_or("(unknown)")
5431 .to_string();
5432 let already = init_result
5433 .get("already_initialized")
5434 .and_then(|v| v.as_bool())
5435 .unwrap_or(false);
5436 if already {
5437 println!("(identity {did} already initialized — reusing)");
5438 } else {
5439 println!("initialized {did}");
5440 }
5441 println!();
5442 match code {
5443 None => cmd_pair_host_detach(relay, false),
5444 Some(c) => cmd_pair_join_detach(c, relay, false),
5445 }
5446}
5447
5448fn cmd_pair_host_detach(relay_url: &str, as_json: bool) -> Result<()> {
5449 if !config::is_initialized()? {
5450 bail!("not initialized — run `wire init <handle>` first");
5451 }
5452 let daemon_spawned = match crate::ensure_up::ensure_daemon_running() {
5453 Ok(b) => b,
5454 Err(e) => {
5455 if !as_json {
5456 eprintln!(
5457 "warn: could not auto-start daemon: {e}; pair will queue but not advance"
5458 );
5459 }
5460 false
5461 }
5462 };
5463 let code = crate::sas::generate_code_phrase();
5464 let code_hash = crate::pair_session::derive_code_hash(&code);
5465 let now = time::OffsetDateTime::now_utc()
5466 .format(&time::format_description::well_known::Rfc3339)
5467 .unwrap_or_default();
5468 let p = crate::pending_pair::PendingPair {
5469 code: code.clone(),
5470 code_hash,
5471 role: "host".to_string(),
5472 relay_url: relay_url.to_string(),
5473 status: "request_host".to_string(),
5474 sas: None,
5475 peer_did: None,
5476 created_at: now,
5477 last_error: None,
5478 pair_id: None,
5479 our_slot_id: None,
5480 our_slot_token: None,
5481 spake2_seed_b64: None,
5482 };
5483 crate::pending_pair::write_pending(&p)?;
5484 if as_json {
5485 println!(
5486 "{}",
5487 serde_json::to_string(&json!({
5488 "state": "queued",
5489 "code_phrase": code,
5490 "relay_url": relay_url,
5491 "role": "host",
5492 "daemon_spawned": daemon_spawned,
5493 }))?
5494 );
5495 } else {
5496 if daemon_spawned {
5497 println!("(started wire daemon in background)");
5498 }
5499 println!("detached pair-host queued. Share this code with your peer:\n");
5500 println!(" {code}\n");
5501 println!("Next steps:");
5502 println!(" wire pair-list # check status");
5503 println!(" wire pair-confirm {code} <digits> # when SAS shows up");
5504 println!(" wire pair-cancel {code} # to abort");
5505 }
5506 Ok(())
5507}
5508
5509fn cmd_pair_join_detach(code_phrase: &str, relay_url: &str, as_json: bool) -> Result<()> {
5510 if !config::is_initialized()? {
5511 bail!("not initialized — run `wire init <handle>` first");
5512 }
5513 let daemon_spawned = match crate::ensure_up::ensure_daemon_running() {
5514 Ok(b) => b,
5515 Err(e) => {
5516 if !as_json {
5517 eprintln!(
5518 "warn: could not auto-start daemon: {e}; pair will queue but not advance"
5519 );
5520 }
5521 false
5522 }
5523 };
5524 let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
5525 let code_hash = crate::pair_session::derive_code_hash(&code);
5526 let now = time::OffsetDateTime::now_utc()
5527 .format(&time::format_description::well_known::Rfc3339)
5528 .unwrap_or_default();
5529 let p = crate::pending_pair::PendingPair {
5530 code: code.clone(),
5531 code_hash,
5532 role: "guest".to_string(),
5533 relay_url: relay_url.to_string(),
5534 status: "request_guest".to_string(),
5535 sas: None,
5536 peer_did: None,
5537 created_at: now,
5538 last_error: None,
5539 pair_id: None,
5540 our_slot_id: None,
5541 our_slot_token: None,
5542 spake2_seed_b64: None,
5543 };
5544 crate::pending_pair::write_pending(&p)?;
5545 if as_json {
5546 println!(
5547 "{}",
5548 serde_json::to_string(&json!({
5549 "state": "queued",
5550 "code_phrase": code,
5551 "relay_url": relay_url,
5552 "role": "guest",
5553 "daemon_spawned": daemon_spawned,
5554 }))?
5555 );
5556 } else {
5557 if daemon_spawned {
5558 println!("(started wire daemon in background)");
5559 }
5560 println!("detached pair-join queued for code {code}.");
5561 println!(
5562 "Run `wire pair-list` to watch for SAS, then `wire pair-confirm {code} <digits>`."
5563 );
5564 }
5565 Ok(())
5566}
5567
5568fn cmd_pair_confirm(code_phrase: &str, typed_digits: &str, as_json: bool) -> Result<()> {
5569 let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
5570 let typed: String = typed_digits
5571 .chars()
5572 .filter(|c| c.is_ascii_digit())
5573 .collect();
5574 if typed.len() != 6 {
5575 bail!(
5576 "expected 6 digits (got {} after stripping non-digits)",
5577 typed.len()
5578 );
5579 }
5580 let mut p = crate::pending_pair::read_pending(&code)?
5581 .ok_or_else(|| anyhow!("no pending pair found for code {code}"))?;
5582 if p.status != "sas_ready" {
5583 bail!(
5584 "pair {code} not in sas_ready state (current: {}). Run `wire pair-list` to see what's going on.",
5585 p.status
5586 );
5587 }
5588 let stored = p
5589 .sas
5590 .as_ref()
5591 .ok_or_else(|| anyhow!("pending file has status=sas_ready but no sas field"))?
5592 .clone();
5593 if stored == typed {
5594 p.status = "confirmed".to_string();
5595 crate::pending_pair::write_pending(&p)?;
5596 if as_json {
5597 println!(
5598 "{}",
5599 serde_json::to_string(&json!({
5600 "state": "confirmed",
5601 "code_phrase": code,
5602 }))?
5603 );
5604 } else {
5605 println!("digits match. Daemon will finalize the handshake on its next tick.");
5606 println!("Run `wire peers` after a few seconds to confirm.");
5607 }
5608 } else {
5609 p.status = "aborted".to_string();
5610 p.last_error = Some(format!(
5611 "SAS digit mismatch (typed {typed}, expected {stored})"
5612 ));
5613 let client = crate::relay_client::RelayClient::new(&p.relay_url);
5614 let _ = client.pair_abandon(&p.code_hash);
5615 crate::pending_pair::write_pending(&p)?;
5616 crate::os_notify::toast(
5617 &format!("wire — pair aborted ({})", p.code),
5618 p.last_error.as_deref().unwrap_or("digits mismatch"),
5619 );
5620 if as_json {
5621 println!(
5622 "{}",
5623 serde_json::to_string(&json!({
5624 "state": "aborted",
5625 "code_phrase": code,
5626 "error": "digits mismatch",
5627 }))?
5628 );
5629 }
5630 bail!("digits mismatch — pair aborted. Re-issue with a fresh `wire pair-host --detach`.");
5631 }
5632 Ok(())
5633}
5634
5635fn cmd_pair_list(as_json: bool, watch: bool, watch_interval_secs: u64) -> Result<()> {
5636 if watch {
5637 return cmd_pair_list_watch(watch_interval_secs);
5638 }
5639 let spake2_items = crate::pending_pair::list_pending()?;
5640 let inbound_items = crate::pending_inbound_pair::list_pending_inbound()?;
5641 if as_json {
5642 println!("{}", serde_json::to_string(&spake2_items)?);
5647 return Ok(());
5648 }
5649 if spake2_items.is_empty() && inbound_items.is_empty() {
5650 println!("no pending pair sessions.");
5651 return Ok(());
5652 }
5653 if !inbound_items.is_empty() {
5656 println!("PENDING INBOUND (v0.5.14 zero-paste pair_drop awaiting your accept)");
5657 println!(
5658 "{:<20} {:<35} {:<25} NEXT STEP",
5659 "PEER", "RELAY", "RECEIVED"
5660 );
5661 for p in &inbound_items {
5662 println!(
5663 "{:<20} {:<35} {:<25} `wire pair-accept {peer}` to accept; `wire pair-reject {peer}` to refuse",
5664 p.peer_handle,
5665 p.peer_relay_url,
5666 p.received_at,
5667 peer = p.peer_handle,
5668 );
5669 }
5670 println!();
5671 }
5672 if !spake2_items.is_empty() {
5673 println!("SPAKE2 SESSIONS");
5674 println!(
5675 "{:<15} {:<8} {:<18} {:<10} NOTE",
5676 "CODE", "ROLE", "STATUS", "SAS"
5677 );
5678 for p in spake2_items {
5679 let sas = p
5680 .sas
5681 .as_ref()
5682 .map(|d| format!("{}-{}", &d[..3], &d[3..]))
5683 .unwrap_or_else(|| "—".to_string());
5684 let note = p
5685 .last_error
5686 .as_deref()
5687 .or(p.peer_did.as_deref())
5688 .unwrap_or("");
5689 println!(
5690 "{:<15} {:<8} {:<18} {:<10} {}",
5691 p.code, p.role, p.status, sas, note
5692 );
5693 }
5694 }
5695 Ok(())
5696}
5697
5698fn cmd_pair_list_watch(interval_secs: u64) -> Result<()> {
5710 use std::collections::HashMap;
5711 use std::io::Write;
5712 let interval = std::time::Duration::from_secs(interval_secs.max(1));
5713 let mut prev: HashMap<String, String> = HashMap::new();
5716 {
5717 let items = crate::pending_pair::list_pending()?;
5718 for p in &items {
5719 println!("{}", serde_json::to_string(&p)?);
5720 prev.insert(p.code.clone(), p.status.clone());
5721 }
5722 let _ = std::io::stdout().flush();
5724 }
5725 loop {
5726 std::thread::sleep(interval);
5727 let items = match crate::pending_pair::list_pending() {
5728 Ok(v) => v,
5729 Err(_) => continue,
5730 };
5731 let mut cur: HashMap<String, String> = HashMap::new();
5732 for p in &items {
5733 cur.insert(p.code.clone(), p.status.clone());
5734 match prev.get(&p.code) {
5735 None => {
5736 println!("{}", serde_json::to_string(&p)?);
5738 }
5739 Some(prev_status) if prev_status != &p.status => {
5740 println!("{}", serde_json::to_string(&p)?);
5742 }
5743 _ => {}
5744 }
5745 }
5746 for code in prev.keys() {
5747 if !cur.contains_key(code) {
5748 println!(
5751 "{}",
5752 serde_json::to_string(&json!({
5753 "code": code,
5754 "status": "removed",
5755 "_synthetic": true,
5756 }))?
5757 );
5758 }
5759 }
5760 let _ = std::io::stdout().flush();
5761 prev = cur;
5762 }
5763}
5764
5765fn cmd_pair_watch(
5769 code_phrase: &str,
5770 target_status: &str,
5771 timeout_secs: u64,
5772 as_json: bool,
5773) -> Result<()> {
5774 let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
5775 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
5776 let mut last_seen_status: Option<String> = None;
5777 loop {
5778 let p_opt = crate::pending_pair::read_pending(&code)?;
5779 let now = std::time::Instant::now();
5780 match p_opt {
5781 None => {
5782 if last_seen_status.is_some() {
5786 if as_json {
5787 println!(
5788 "{}",
5789 serde_json::to_string(&json!({"state": "finalized", "code": code}))?
5790 );
5791 } else {
5792 println!("pair {code} finalized (file removed)");
5793 }
5794 return Ok(());
5795 } else {
5796 if as_json {
5797 println!(
5798 "{}",
5799 serde_json::to_string(&json!({"error": "no such pair", "code": code}))?
5800 );
5801 }
5802 std::process::exit(1);
5803 }
5804 }
5805 Some(p) => {
5806 let cur = p.status.clone();
5807 if Some(cur.clone()) != last_seen_status {
5808 if as_json {
5809 println!("{}", serde_json::to_string(&p)?);
5811 }
5812 last_seen_status = Some(cur.clone());
5813 }
5814 if cur == target_status {
5815 if !as_json {
5816 let sas_str = p
5817 .sas
5818 .as_ref()
5819 .map(|s| format!("{}-{}", &s[..3], &s[3..]))
5820 .unwrap_or_else(|| "—".to_string());
5821 println!("pair {code} reached {target_status} (SAS: {sas_str})");
5822 }
5823 return Ok(());
5824 }
5825 if cur == "aborted" || cur == "aborted_restart" {
5826 if !as_json {
5827 let err = p.last_error.as_deref().unwrap_or("(no detail)");
5828 eprintln!("pair {code} {cur}: {err}");
5829 }
5830 std::process::exit(1);
5831 }
5832 }
5833 }
5834 if now >= deadline {
5835 if !as_json {
5836 eprintln!(
5837 "timeout after {timeout_secs}s waiting for pair {code} to reach {target_status}"
5838 );
5839 }
5840 std::process::exit(2);
5841 }
5842 std::thread::sleep(std::time::Duration::from_millis(250));
5843 }
5844}
5845
5846fn cmd_pair_cancel(code_phrase: &str, as_json: bool) -> Result<()> {
5847 let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
5848 let p = crate::pending_pair::read_pending(&code)?
5849 .ok_or_else(|| anyhow!("no pending pair for code {code}"))?;
5850 let client = crate::relay_client::RelayClient::new(&p.relay_url);
5851 let _ = client.pair_abandon(&p.code_hash);
5852 crate::pending_pair::delete_pending(&code)?;
5853 if as_json {
5854 println!(
5855 "{}",
5856 serde_json::to_string(&json!({
5857 "state": "cancelled",
5858 "code_phrase": code,
5859 }))?
5860 );
5861 } else {
5862 println!("cancelled pending pair {code} (relay slot released, file removed).");
5863 }
5864 Ok(())
5865}
5866
5867fn cmd_pair_abandon(code_phrase: &str, relay_url: &str) -> Result<()> {
5870 let code = crate::sas::parse_code_phrase(code_phrase)?;
5873 let code_hash = crate::pair_session::derive_code_hash(code);
5874 let client = crate::relay_client::RelayClient::new(relay_url);
5875 client.pair_abandon(&code_hash)?;
5876 println!("abandoned pair-slot for code {code_phrase} on {relay_url}");
5877 println!("host can now issue a fresh code; guest can re-join.");
5878 Ok(())
5879}
5880
5881fn cmd_invite(relay: &str, ttl: u64, uses: u32, share: bool, as_json: bool) -> Result<()> {
5884 let url = crate::pair_invite::mint_invite(Some(ttl), uses, Some(relay))?;
5885
5886 let share_payload: Option<Value> = if share {
5889 let client = reqwest::blocking::Client::new();
5890 let single_use = if uses == 1 { Some(1u32) } else { None };
5891 let body = json!({
5892 "invite_url": url,
5893 "ttl_seconds": ttl,
5894 "uses": single_use,
5895 });
5896 let endpoint = format!("{}/v1/invite/register", relay.trim_end_matches('/'));
5897 let resp = client.post(&endpoint).json(&body).send()?;
5898 if !resp.status().is_success() {
5899 let code = resp.status();
5900 let txt = resp.text().unwrap_or_default();
5901 bail!("relay {code} on /v1/invite/register: {txt}");
5902 }
5903 let parsed: Value = resp.json()?;
5904 let token = parsed
5905 .get("token")
5906 .and_then(Value::as_str)
5907 .ok_or_else(|| anyhow::anyhow!("relay reply missing token"))?
5908 .to_string();
5909 let share_url = format!("{}/i/{}", relay.trim_end_matches('/'), token);
5910 let curl_line = format!("curl -fsSL {share_url} | sh");
5911 Some(json!({
5912 "token": token,
5913 "share_url": share_url,
5914 "curl": curl_line,
5915 "expires_unix": parsed.get("expires_unix"),
5916 }))
5917 } else {
5918 None
5919 };
5920
5921 if as_json {
5922 let mut out = json!({
5923 "invite_url": url,
5924 "ttl_secs": ttl,
5925 "uses": uses,
5926 "relay": relay,
5927 });
5928 if let Some(s) = &share_payload {
5929 out["share"] = s.clone();
5930 }
5931 println!("{}", serde_json::to_string(&out)?);
5932 } else if let Some(s) = share_payload {
5933 let curl = s.get("curl").and_then(Value::as_str).unwrap_or("");
5934 eprintln!("# One-curl onboarding. Share this single line — installs wire if missing,");
5935 eprintln!("# accepts the invite, pairs both sides. TTL: {ttl}s. Uses: {uses}.");
5936 println!("{curl}");
5937 } else {
5938 eprintln!("# Share this URL with one peer. Pasting it = pair complete on their side.");
5939 eprintln!("# TTL: {ttl}s. Uses: {uses}.");
5940 println!("{url}");
5941 }
5942 Ok(())
5943}
5944
5945fn cmd_accept(url: &str, as_json: bool) -> Result<()> {
5946 let resolved = if url.starts_with("http://") || url.starts_with("https://") {
5950 let sep = if url.contains('?') { '&' } else { '?' };
5951 let resolve_url = format!("{url}{sep}format=url");
5952 let client = reqwest::blocking::Client::new();
5953 let resp = client
5954 .get(&resolve_url)
5955 .send()
5956 .with_context(|| format!("GET {resolve_url}"))?;
5957 if !resp.status().is_success() {
5958 bail!("could not resolve short URL {url} (HTTP {})", resp.status());
5959 }
5960 let body = resp.text().unwrap_or_default().trim().to_string();
5961 if !body.starts_with("wire://pair?") {
5962 bail!(
5963 "short URL {url} did not resolve to a wire:// invite. \
5964 (got: {}{})",
5965 body.chars().take(80).collect::<String>(),
5966 if body.chars().count() > 80 { "…" } else { "" }
5967 );
5968 }
5969 body
5970 } else {
5971 url.to_string()
5972 };
5973
5974 let result = crate::pair_invite::accept_invite(&resolved)?;
5975 if as_json {
5976 println!("{}", serde_json::to_string(&result)?);
5977 } else {
5978 let did = result
5979 .get("paired_with")
5980 .and_then(Value::as_str)
5981 .unwrap_or("?");
5982 println!("paired with {did}");
5983 println!(
5984 "you can now: wire send {} <kind> <body>",
5985 crate::agent_card::display_handle_from_did(did)
5986 );
5987 }
5988 Ok(())
5989}
5990
5991fn cmd_whois(handle: Option<&str>, as_json: bool, relay_override: Option<&str>) -> Result<()> {
5994 if let Some(h) = handle {
5995 let parsed = crate::pair_profile::parse_handle(h)?;
5996 if config::is_initialized()? {
5999 let card = config::read_agent_card()?;
6000 let local_handle = card
6001 .get("profile")
6002 .and_then(|p| p.get("handle"))
6003 .and_then(Value::as_str)
6004 .map(str::to_string);
6005 if local_handle.as_deref() == Some(h) {
6006 return cmd_whois(None, as_json, None);
6007 }
6008 }
6009 let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)?;
6011 if as_json {
6012 println!("{}", serde_json::to_string(&resolved)?);
6013 } else {
6014 print_resolved_profile(&resolved);
6015 }
6016 return Ok(());
6017 }
6018 let card = config::read_agent_card()?;
6019 if as_json {
6020 let profile = card.get("profile").cloned().unwrap_or(Value::Null);
6021 println!(
6022 "{}",
6023 serde_json::to_string(&json!({
6024 "did": card.get("did").cloned().unwrap_or(Value::Null),
6025 "profile": profile,
6026 }))?
6027 );
6028 } else {
6029 print!("{}", crate::pair_profile::render_self_summary()?);
6030 }
6031 Ok(())
6032}
6033
6034fn print_resolved_profile(resolved: &Value) {
6035 let did = resolved.get("did").and_then(Value::as_str).unwrap_or("?");
6036 let nick = resolved.get("nick").and_then(Value::as_str).unwrap_or("?");
6037 let relay = resolved
6038 .get("relay_url")
6039 .and_then(Value::as_str)
6040 .unwrap_or("");
6041 let slot = resolved
6042 .get("slot_id")
6043 .and_then(Value::as_str)
6044 .unwrap_or("");
6045 let profile = resolved
6046 .get("card")
6047 .and_then(|c| c.get("profile"))
6048 .cloned()
6049 .unwrap_or(Value::Null);
6050 println!("{did}");
6051 println!(" nick: {nick}");
6052 if !relay.is_empty() {
6053 println!(" relay_url: {relay}");
6054 }
6055 if !slot.is_empty() {
6056 println!(" slot_id: {slot}");
6057 }
6058 let pick =
6059 |k: &str| -> Option<String> { profile.get(k).and_then(Value::as_str).map(str::to_string) };
6060 if let Some(s) = pick("display_name") {
6061 println!(" display_name: {s}");
6062 }
6063 if let Some(s) = pick("emoji") {
6064 println!(" emoji: {s}");
6065 }
6066 if let Some(s) = pick("motto") {
6067 println!(" motto: {s}");
6068 }
6069 if let Some(arr) = profile.get("vibe").and_then(Value::as_array) {
6070 let joined: Vec<String> = arr
6071 .iter()
6072 .filter_map(|v| v.as_str().map(str::to_string))
6073 .collect();
6074 println!(" vibe: {}", joined.join(", "));
6075 }
6076 if let Some(s) = pick("pronouns") {
6077 println!(" pronouns: {s}");
6078 }
6079}
6080
6081fn host_of_url(url: &str) -> String {
6089 let no_scheme = url
6090 .trim_start_matches("https://")
6091 .trim_start_matches("http://");
6092 no_scheme
6093 .split('/')
6094 .next()
6095 .unwrap_or("")
6096 .split(':')
6097 .next()
6098 .unwrap_or("")
6099 .to_string()
6100}
6101
6102fn is_known_relay_domain(peer_domain: &str, our_relay_url: &str) -> bool {
6106 const KNOWN_GOOD: &[&str] = &["wireup.net", "wire.laulpogan.com"];
6108 let peer_domain = peer_domain.trim().to_ascii_lowercase();
6109 if KNOWN_GOOD.iter().any(|k| *k == peer_domain) {
6110 return true;
6111 }
6112 let our_host = host_of_url(our_relay_url).to_ascii_lowercase();
6115 if !our_host.is_empty() && our_host == peer_domain {
6116 return true;
6117 }
6118 false
6119}
6120
6121fn resolve_local_session<'a>(
6139 sessions: &'a [crate::session::SessionInfo],
6140 input: &str,
6141) -> Result<&'a crate::session::SessionInfo, ResolveError> {
6142 if let Some(s) = sessions.iter().find(|s| s.name == input) {
6145 return Ok(s);
6146 }
6147 let nick_matches: Vec<&crate::session::SessionInfo> = sessions
6148 .iter()
6149 .filter(|s| {
6150 s.character
6151 .as_ref()
6152 .map(|c| c.nickname == input)
6153 .unwrap_or(false)
6154 })
6155 .collect();
6156 match nick_matches.len() {
6157 0 => Err(ResolveError::NotFound),
6158 1 => Ok(nick_matches[0]),
6159 _ => Err(ResolveError::Ambiguous(
6160 nick_matches.iter().map(|s| s.name.clone()).collect(),
6161 )),
6162 }
6163}
6164
6165#[derive(Debug)]
6166enum ResolveError {
6167 NotFound,
6168 Ambiguous(Vec<String>),
6169}
6170
6171fn resolve_peer_handle(input: &str) -> Result<Option<String>, ResolveError> {
6187 let trust = match config::read_trust() {
6188 Ok(t) => t,
6189 Err(_) => return Ok(None),
6190 };
6191 let agents = match trust.get("agents").and_then(|a| a.as_object()) {
6192 Some(a) => a,
6193 None => return Ok(None),
6194 };
6195 if agents.contains_key(input) {
6196 return Ok(Some(input.to_string()));
6197 }
6198 let mut nick_matches: Vec<String> = Vec::new();
6199 for (handle, agent) in agents.iter() {
6200 let character = match agent.get("card") {
6204 Some(card) => crate::character::Character::from_card(card),
6205 None => match agent.get("did").and_then(Value::as_str) {
6206 Some(did) => crate::character::Character::from_did(did),
6207 None => continue,
6208 },
6209 };
6210 if character.nickname == input {
6211 nick_matches.push(handle.clone());
6212 }
6213 }
6214 match nick_matches.len() {
6215 0 => Ok(None),
6216 1 => Ok(Some(nick_matches.into_iter().next().unwrap())),
6217 _ => Err(ResolveError::Ambiguous(nick_matches)),
6218 }
6219}
6220
6221fn cmd_add_local_sister(sister_name: &str, as_json: bool) -> Result<()> {
6222 let sessions = crate::session::list_sessions()?;
6224 let sister = match resolve_local_session(&sessions, sister_name) {
6225 Ok(s) => s,
6226 Err(ResolveError::NotFound) => bail!(
6227 "no sister session named `{sister_name}` (matched by session name or character nickname). \
6228 Run `wire session list` to see what's available."
6229 ),
6230 Err(ResolveError::Ambiguous(candidates)) => bail!(
6231 "nickname `{sister_name}` is ambiguous — matches {} sessions: {}. \
6232 Disambiguate by passing the session name (one of those listed) instead of the nickname.",
6233 candidates.len(),
6234 candidates.join(", ")
6235 ),
6236 };
6237 if sister.name != sister_name {
6240 eprintln!(
6241 "wire add: resolved nickname `{sister_name}` → session `{}`",
6242 sister.name
6243 );
6244 }
6245
6246 let our_card = config::read_agent_card()
6249 .map_err(|_| anyhow!("not initialized — run `wire init <handle>` first"))?;
6250 let our_did = our_card
6251 .get("did")
6252 .and_then(Value::as_str)
6253 .ok_or_else(|| anyhow!("agent-card missing did"))?
6254 .to_string();
6255 if let Some(sister_did) = sister.did.as_deref()
6256 && sister_did == our_did
6257 {
6258 bail!("refusing to add self (`{sister_name}` is this very session)");
6259 }
6260
6261 let sister_card_path = sister
6263 .home_dir
6264 .join("config")
6265 .join("wire")
6266 .join("agent-card.json");
6267 let sister_card: Value = serde_json::from_slice(
6268 &std::fs::read(&sister_card_path)
6269 .with_context(|| format!("reading sister card {sister_card_path:?}"))?,
6270 )
6271 .with_context(|| format!("parsing sister card {sister_card_path:?}"))?;
6272 let sister_relay_state: Value = std::fs::read(
6273 sister
6274 .home_dir
6275 .join("config")
6276 .join("wire")
6277 .join("relay.json"),
6278 )
6279 .ok()
6280 .and_then(|b| serde_json::from_slice(&b).ok())
6281 .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
6282
6283 let sister_did = sister_card
6284 .get("did")
6285 .and_then(Value::as_str)
6286 .ok_or_else(|| anyhow!("sister card missing did"))?
6287 .to_string();
6288 let sister_handle = crate::agent_card::display_handle_from_did(&sister_did).to_string();
6289
6290 let sister_endpoints = crate::endpoints::self_endpoints(&sister_relay_state);
6294 if sister_endpoints.is_empty() {
6295 bail!(
6296 "sister `{sister_name}` has no endpoints in its relay.json — recreate with `wire session new --local-only` or `--with-local`"
6297 );
6298 }
6299 let sister_local = sister_endpoints
6300 .iter()
6301 .find(|e| e.scope == crate::endpoints::EndpointScope::Local);
6302 let delivery_endpoint = match sister_local {
6303 Some(e) => e.clone(),
6304 None => sister_endpoints[0].clone(),
6305 };
6306
6307 let our_relay_state = config::read_relay_state()?;
6313 let our_endpoints = crate::endpoints::self_endpoints(&our_relay_state);
6314 if our_endpoints.is_empty() {
6315 bail!(
6316 "this session has no endpoints — run `wire session new --local-only` or `wire bind-relay` first"
6317 );
6318 }
6319 let our_advertised = our_endpoints
6320 .iter()
6321 .find(|e| e.scope == crate::endpoints::EndpointScope::Federation)
6322 .cloned()
6323 .unwrap_or_else(|| our_endpoints[0].clone());
6324
6325 let mut trust = config::read_trust()?;
6329 crate::trust::add_agent_card_pin(&mut trust, &sister_card, Some("VERIFIED"));
6330 config::write_trust(&trust)?;
6331 let mut relay_state = config::read_relay_state()?;
6332 crate::endpoints::pin_peer_endpoints(&mut relay_state, &sister_handle, &sister_endpoints)?;
6333 config::write_relay_state(&relay_state)?;
6334
6335 let sk_seed = config::read_private_key()?;
6338 let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
6339 let pk_b64 = our_card
6340 .get("verify_keys")
6341 .and_then(Value::as_object)
6342 .and_then(|m| m.values().next())
6343 .and_then(|v| v.get("key"))
6344 .and_then(Value::as_str)
6345 .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
6346 let pk_bytes = crate::signing::b64decode(pk_b64)?;
6347 let now = time::OffsetDateTime::now_utc()
6348 .format(&time::format_description::well_known::Rfc3339)
6349 .unwrap_or_default();
6350 let mut body = json!({
6351 "card": our_card,
6352 "relay_url": our_advertised.relay_url,
6353 "slot_id": our_advertised.slot_id,
6354 "slot_token": our_advertised.slot_token,
6355 });
6356 body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
6357 let event = json!({
6358 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
6359 "timestamp": now,
6360 "from": our_did,
6361 "to": sister_did,
6362 "type": "pair_drop",
6363 "kind": 1100u32,
6364 "body": body,
6365 });
6366 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
6367 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
6368
6369 let client = crate::relay_client::RelayClient::new(&delivery_endpoint.relay_url);
6373 client
6374 .post_event(
6375 &delivery_endpoint.slot_id,
6376 &delivery_endpoint.slot_token,
6377 &signed,
6378 )
6379 .with_context(|| format!("delivering pair_drop to `{sister_name}`'s local slot"))?;
6380
6381 if as_json {
6382 println!(
6383 "{}",
6384 serde_json::to_string(&json!({
6385 "handle": sister_name,
6386 "paired_with": sister_did,
6387 "peer_handle": sister_handle,
6388 "event_id": event_id,
6389 "delivered_via": match delivery_endpoint.scope {
6390 crate::endpoints::EndpointScope::Local => "local",
6391 crate::endpoints::EndpointScope::Lan => "lan",
6392 crate::endpoints::EndpointScope::Uds => "uds",
6393 crate::endpoints::EndpointScope::Federation => "federation",
6394 },
6395 "status": "drop_sent",
6396 }))?
6397 );
6398 } else {
6399 let scope = match delivery_endpoint.scope {
6400 crate::endpoints::EndpointScope::Local => "local",
6401 crate::endpoints::EndpointScope::Lan => "lan",
6402 crate::endpoints::EndpointScope::Uds => "uds",
6403 crate::endpoints::EndpointScope::Federation => "federation",
6404 };
6405 println!(
6406 "→ 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.",
6407 delivery_endpoint.relay_url
6408 );
6409 }
6410 Ok(())
6411}
6412
6413fn cmd_add(
6414 handle_arg: &str,
6415 relay_override: Option<&str>,
6416 local_sister: bool,
6417 as_json: bool,
6418) -> Result<()> {
6419 if local_sister {
6427 let resolved = crate::session::resolve_local_sister(handle_arg)
6428 .unwrap_or_else(|| handle_arg.to_string());
6429 return cmd_add_local_sister(&resolved, as_json);
6430 }
6431 if !handle_arg.contains('@')
6432 && let Some(resolved) = crate::session::resolve_local_sister(handle_arg)
6433 {
6434 eprintln!(
6435 "wire add: `{handle_arg}` resolved to local sister session `{resolved}` \
6436 — routing via --local-sister (disk-read card, no relay lookup)."
6437 );
6438 return cmd_add_local_sister(&resolved, as_json);
6439 }
6440 if !handle_arg.contains('@') {
6441 bail!(
6442 "`{handle_arg}` doesn't match any local sister session and has no \
6443 @<relay> suffix for federation.\n\
6444 — Local sisters: `wire session list-local` (operator types name OR \
6445 character nickname)\n\
6446 — Federation: `wire add <handle>@<relay-domain>` (e.g. \
6447 `wire add alice@wireup.net`)"
6448 );
6449 }
6450 let parsed = crate::pair_profile::parse_handle(handle_arg)?;
6451
6452 let (our_did, our_relay, our_slot_id, our_slot_token) =
6454 crate::pair_invite::ensure_self_with_relay(relay_override)?;
6455 if our_did == format!("did:wire:{}", parsed.nick) {
6456 bail!("refusing to add self (handle matches own DID)");
6458 }
6459
6460 if let Some(pending) = crate::pending_inbound_pair::read_pending_inbound(&parsed.nick)? {
6470 return cmd_add_accept_pending(
6471 handle_arg,
6472 &parsed.nick,
6473 &pending,
6474 &our_relay,
6475 &our_slot_id,
6476 &our_slot_token,
6477 as_json,
6478 );
6479 }
6480
6481 if !is_known_relay_domain(&parsed.domain, &our_relay) {
6498 eprintln!(
6499 "wire add: WARN unfamiliar relay domain `{}`.",
6500 parsed.domain
6501 );
6502 eprintln!(
6503 " This is NOT `wireup.net` (the default), NOT your own relay (`{}`), ",
6504 host_of_url(&our_relay)
6505 );
6506 eprintln!(
6507 " and not on the known-good list. If you meant `{}@wireup.net`, ",
6508 parsed.nick
6509 );
6510 eprintln!(
6511 " run `wire add {}@wireup.net` instead. Otherwise verify with your",
6512 parsed.nick
6513 );
6514 eprintln!(" peer out-of-band that they actually run a relay at this domain");
6515 eprintln!(" before relying on the pair. (See issue #9.4.)");
6516 }
6517
6518 let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)?;
6520 let peer_card = resolved
6521 .get("card")
6522 .cloned()
6523 .ok_or_else(|| anyhow!("resolved missing card"))?;
6524 let peer_did = resolved
6525 .get("did")
6526 .and_then(Value::as_str)
6527 .ok_or_else(|| anyhow!("resolved missing did"))?
6528 .to_string();
6529 let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
6530 let peer_slot_id = resolved
6531 .get("slot_id")
6532 .and_then(Value::as_str)
6533 .ok_or_else(|| anyhow!("resolved missing slot_id"))?
6534 .to_string();
6535 let peer_relay = resolved
6536 .get("relay_url")
6537 .and_then(Value::as_str)
6538 .map(str::to_string)
6539 .or_else(|| relay_override.map(str::to_string))
6540 .unwrap_or_else(|| format!("https://{}", parsed.domain));
6541
6542 let mut trust = config::read_trust()?;
6544 crate::trust::add_agent_card_pin(&mut trust, &peer_card, Some("VERIFIED"));
6545 config::write_trust(&trust)?;
6546 let mut relay_state = config::read_relay_state()?;
6547 let existing_token = relay_state
6548 .get("peers")
6549 .and_then(|p| p.get(&peer_handle))
6550 .and_then(|p| p.get("slot_token"))
6551 .and_then(Value::as_str)
6552 .map(str::to_string)
6553 .unwrap_or_default();
6554 relay_state["peers"][&peer_handle] = json!({
6555 "relay_url": peer_relay,
6556 "slot_id": peer_slot_id,
6557 "slot_token": existing_token, });
6559 config::write_relay_state(&relay_state)?;
6560
6561 let our_card = config::read_agent_card()?;
6564 let sk_seed = config::read_private_key()?;
6565 let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
6566 let pk_b64 = our_card
6567 .get("verify_keys")
6568 .and_then(Value::as_object)
6569 .and_then(|m| m.values().next())
6570 .and_then(|v| v.get("key"))
6571 .and_then(Value::as_str)
6572 .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
6573 let pk_bytes = crate::signing::b64decode(pk_b64)?;
6574 let now = time::OffsetDateTime::now_utc()
6575 .format(&time::format_description::well_known::Rfc3339)
6576 .unwrap_or_default();
6577 let our_relay_state = config::read_relay_state().unwrap_or_else(|_| json!({}));
6582 let our_endpoints = crate::endpoints::self_endpoints(&our_relay_state);
6583 let mut body = json!({
6584 "card": our_card,
6585 "relay_url": our_relay,
6586 "slot_id": our_slot_id,
6587 "slot_token": our_slot_token,
6588 });
6589 if !our_endpoints.is_empty() {
6590 body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
6591 }
6592 let event = json!({
6593 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
6594 "timestamp": now,
6595 "from": our_did,
6596 "to": peer_did,
6597 "type": "pair_drop",
6598 "kind": 1100u32,
6599 "body": body,
6600 });
6601 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
6602
6603 let client = crate::relay_client::RelayClient::new(&peer_relay);
6605 let resp = client.handle_intro(&parsed.nick, &signed)?;
6606 let event_id = signed
6607 .get("event_id")
6608 .and_then(Value::as_str)
6609 .unwrap_or("")
6610 .to_string();
6611
6612 if as_json {
6613 println!(
6614 "{}",
6615 serde_json::to_string(&json!({
6616 "handle": handle_arg,
6617 "paired_with": peer_did,
6618 "peer_handle": peer_handle,
6619 "event_id": event_id,
6620 "drop_response": resp,
6621 "status": "drop_sent",
6622 }))?
6623 );
6624 } else {
6625 println!(
6626 "→ 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."
6627 );
6628 }
6629 Ok(())
6630}
6631
6632fn cmd_add_accept_pending(
6639 handle_arg: &str,
6640 peer_nick: &str,
6641 pending: &crate::pending_inbound_pair::PendingInboundPair,
6642 _our_relay: &str,
6643 _our_slot_id: &str,
6644 _our_slot_token: &str,
6645 as_json: bool,
6646) -> Result<()> {
6647 let mut trust = config::read_trust()?;
6650 crate::trust::add_agent_card_pin(&mut trust, &pending.peer_card, Some("VERIFIED"));
6651 config::write_trust(&trust)?;
6652
6653 let mut relay_state = config::read_relay_state()?;
6659 let endpoints_to_pin = if pending.peer_endpoints.is_empty() {
6660 vec![crate::endpoints::Endpoint::federation(
6661 pending.peer_relay_url.clone(),
6662 pending.peer_slot_id.clone(),
6663 pending.peer_slot_token.clone(),
6664 )]
6665 } else {
6666 pending.peer_endpoints.clone()
6667 };
6668 crate::endpoints::pin_peer_endpoints(
6669 &mut relay_state,
6670 &pending.peer_handle,
6671 &endpoints_to_pin,
6672 )?;
6673 config::write_relay_state(&relay_state)?;
6674
6675 crate::pair_invite::send_pair_drop_ack(
6677 &pending.peer_handle,
6678 &pending.peer_relay_url,
6679 &pending.peer_slot_id,
6680 &pending.peer_slot_token,
6681 )
6682 .with_context(|| {
6683 format!(
6684 "pair_drop_ack send to {} @ {} slot {} failed",
6685 pending.peer_handle, pending.peer_relay_url, pending.peer_slot_id
6686 )
6687 })?;
6688
6689 crate::pending_inbound_pair::consume_pending_inbound(peer_nick)?;
6691
6692 if as_json {
6693 println!(
6694 "{}",
6695 serde_json::to_string(&json!({
6696 "handle": handle_arg,
6697 "paired_with": pending.peer_did,
6698 "peer_handle": pending.peer_handle,
6699 "status": "bilateral_accepted",
6700 "via": "pending_inbound",
6701 }))?
6702 );
6703 } else {
6704 println!(
6705 "→ 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} \"...\"`.",
6706 peer = pending.peer_handle,
6707 );
6708 }
6709 Ok(())
6710}
6711
6712fn cmd_pair_accept(peer_nick: &str, as_json: bool) -> Result<()> {
6719 let nick = crate::agent_card::bare_handle(peer_nick);
6720 let pending = crate::pending_inbound_pair::read_pending_inbound(nick)?.ok_or_else(|| {
6721 anyhow!(
6722 "no pending pair request from {nick}. Run `wire pair-list-inbound` to see who is waiting, \
6723 or use `wire add <peer>@<relay>` to send a fresh outbound pair request."
6724 )
6725 })?;
6726 let (_our_did, our_relay, our_slot_id, our_slot_token) =
6727 crate::pair_invite::ensure_self_with_relay(None)?;
6728 let handle_arg = format!("{}@{}", pending.peer_handle, pending.peer_relay_url);
6729 cmd_add_accept_pending(
6730 &handle_arg,
6731 nick,
6732 &pending,
6733 &our_relay,
6734 &our_slot_id,
6735 &our_slot_token,
6736 as_json,
6737 )
6738}
6739
6740fn cmd_pair_list_inbound(as_json: bool) -> Result<()> {
6743 let items = crate::pending_inbound_pair::list_pending_inbound()?;
6744 if as_json {
6745 println!("{}", serde_json::to_string(&items)?);
6746 return Ok(());
6747 }
6748 if items.is_empty() {
6749 println!("no pending pair requests — your inbox is clear.");
6750 return Ok(());
6751 }
6752 let plural = if items.len() == 1 { "" } else { "s" };
6759 println!("{} pending pair request{plural}:\n", items.len());
6760 for p in &items {
6761 let ch = crate::character::Character::from_did(&p.peer_did);
6762 let glyph = crate::character::emoji_with_fallback(&ch);
6763 println!(
6766 " {glyph} {nick} ({handle}) wants to pair with you",
6767 nick = ch.nickname,
6768 handle = p.peer_handle,
6769 );
6770 }
6771 println!();
6772 println!(
6773 "→ to accept any: `wire accept <name>` (e.g. `wire accept {first}`)",
6774 first = items
6775 .first()
6776 .map(|p| {
6777 let ch = crate::character::Character::from_did(&p.peer_did);
6778 ch.nickname
6779 })
6780 .unwrap_or_else(|| "<name>".to_string())
6781 );
6782 println!("→ to refuse: `wire reject <name>`");
6783 Ok(())
6784}
6785
6786fn cmd_pair_reject(peer_nick: &str, as_json: bool) -> Result<()> {
6790 let nick = crate::agent_card::bare_handle(peer_nick);
6791 let existed = crate::pending_inbound_pair::read_pending_inbound(nick)?;
6792 crate::pending_inbound_pair::consume_pending_inbound(nick)?;
6793
6794 if as_json {
6795 println!(
6796 "{}",
6797 serde_json::to_string(&json!({
6798 "peer": nick,
6799 "rejected": existed.is_some(),
6800 "had_pending": existed.is_some(),
6801 }))?
6802 );
6803 } else if existed.is_some() {
6804 println!(
6805 "→ rejected pending pair from {nick}\n→ pending-inbound record deleted; no ack sent."
6806 );
6807 } else {
6808 println!("no pending pair from {nick} — nothing to reject");
6809 }
6810 Ok(())
6811}
6812
6813fn cmd_mesh(cmd: MeshCommand) -> Result<()> {
6824 match cmd {
6825 MeshCommand::Status { stale_secs, json } => cmd_session_mesh_status(stale_secs, json),
6826 MeshCommand::Broadcast {
6827 kind,
6828 scope,
6829 exclude,
6830 noreply,
6831 body,
6832 json,
6833 } => cmd_mesh_broadcast(&kind, &scope, &exclude, noreply, &body, json),
6834 MeshCommand::Role { action } => cmd_mesh_role(action),
6835 MeshCommand::Route {
6836 role,
6837 strategy,
6838 exclude,
6839 kind,
6840 body,
6841 json,
6842 } => cmd_mesh_route(&role, &strategy, &exclude, &kind, &body, json),
6843 }
6844}
6845
6846fn cmd_mesh_route(
6851 role: &str,
6852 strategy: &str,
6853 exclude: &[String],
6854 kind: &str,
6855 body_arg: &str,
6856 as_json: bool,
6857) -> Result<()> {
6858 use std::time::Instant;
6859
6860 if !config::is_initialized()? {
6861 bail!("not initialized — run `wire init <handle>` first");
6862 }
6863 let strategy = strategy.to_ascii_lowercase();
6864 if !matches!(strategy.as_str(), "round-robin" | "first" | "random") {
6865 bail!("unknown strategy `{strategy}` — use round-robin | first | random");
6866 }
6867
6868 let state = config::read_relay_state()?;
6871 let pinned: std::collections::BTreeSet<String> = state["peers"]
6872 .as_object()
6873 .map(|m| m.keys().cloned().collect())
6874 .unwrap_or_default();
6875
6876 let exclude_set: std::collections::HashSet<&str> = exclude.iter().map(String::as_str).collect();
6877
6878 let sessions = crate::session::list_sessions()?;
6883 let mut candidates: Vec<(String, Option<String>)> = Vec::new(); for s in &sessions {
6885 let handle = match s.handle.as_ref() {
6886 Some(h) => h.clone(),
6887 None => continue,
6888 };
6889 if exclude_set.contains(handle.as_str()) {
6890 continue;
6891 }
6892 if !pinned.contains(&handle) {
6893 continue;
6894 }
6895 let card_path = s
6896 .home_dir
6897 .join("config")
6898 .join("wire")
6899 .join("agent-card.json");
6900 let card_role = std::fs::read(&card_path)
6901 .ok()
6902 .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
6903 .and_then(|c| {
6904 c.get("profile")
6905 .and_then(|p| p.get("role"))
6906 .and_then(Value::as_str)
6907 .map(str::to_string)
6908 });
6909 if card_role.as_deref() == Some(role) {
6910 candidates.push((handle, s.did.clone()));
6911 }
6912 }
6913
6914 candidates.sort_by(|a, b| a.0.cmp(&b.0));
6915 candidates.dedup_by(|a, b| a.0 == b.0);
6916
6917 if candidates.is_empty() {
6918 bail!(
6919 "no pinned sister with role=`{role}` (run `wire mesh role list` to see what's available)"
6920 );
6921 }
6922
6923 let chosen = match strategy.as_str() {
6924 "first" => candidates[0].clone(),
6925 "random" => {
6926 use rand::Rng;
6927 let idx = rand::thread_rng().gen_range(0..candidates.len());
6928 candidates[idx].clone()
6929 }
6930 "round-robin" => {
6931 let cursor_path = mesh_route_cursor_path()?;
6936 let mut cursors: std::collections::BTreeMap<String, String> =
6937 read_mesh_route_cursors(&cursor_path);
6938 let last = cursors.get(role).cloned();
6939 let pick = match last {
6940 None => candidates[0].clone(),
6941 Some(last_h) => candidates
6942 .iter()
6943 .find(|(h, _)| h.as_str() > last_h.as_str())
6944 .cloned()
6945 .unwrap_or_else(|| candidates[0].clone()),
6946 };
6947 cursors.insert(role.to_string(), pick.0.clone());
6948 write_mesh_route_cursors(&cursor_path, &cursors)?;
6949 pick
6950 }
6951 _ => unreachable!(),
6952 };
6953
6954 let (chosen_handle, _chosen_did) = chosen;
6955
6956 let body_value: Value = if body_arg == "-" {
6958 use std::io::Read;
6959 let mut raw = String::new();
6960 std::io::stdin()
6961 .read_to_string(&mut raw)
6962 .with_context(|| "reading body from stdin")?;
6963 serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
6964 } else if let Some(path) = body_arg.strip_prefix('@') {
6965 let raw =
6966 std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
6967 serde_json::from_str(&raw).unwrap_or(Value::String(raw))
6968 } else {
6969 Value::String(body_arg.to_string())
6970 };
6971
6972 let sk_seed = config::read_private_key()?;
6973 let card = config::read_agent_card()?;
6974 let did = card
6975 .get("did")
6976 .and_then(Value::as_str)
6977 .ok_or_else(|| anyhow!("agent-card missing did"))?
6978 .to_string();
6979 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
6980 let pk_b64 = card
6981 .get("verify_keys")
6982 .and_then(Value::as_object)
6983 .and_then(|m| m.values().next())
6984 .and_then(|v| v.get("key"))
6985 .and_then(Value::as_str)
6986 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
6987 let pk_bytes = crate::signing::b64decode(pk_b64)?;
6988
6989 let kind_id = parse_kind(kind)?;
6990 let now_iso = time::OffsetDateTime::now_utc()
6991 .format(&time::format_description::well_known::Rfc3339)
6992 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
6993
6994 let event = json!({
6995 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
6996 "timestamp": now_iso,
6997 "from": did,
6998 "to": format!("did:wire:{chosen_handle}"),
6999 "type": kind,
7000 "kind": kind_id,
7001 "body": json!({
7002 "content": body_value,
7003 "routed_via": {
7004 "role": role,
7005 "strategy": strategy,
7006 },
7007 }),
7008 });
7009 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)
7010 .map_err(|e| anyhow!("sign_message_v31 failed: {e:?}"))?;
7011 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
7012
7013 let line = serde_json::to_vec(&signed)?;
7014 config::append_outbox_record(&chosen_handle, &line)?;
7015
7016 let endpoints = crate::endpoints::peer_endpoints_in_priority_order(&state, &chosen_handle);
7017 if endpoints.is_empty() {
7018 bail!(
7019 "no reachable endpoint pinned for `{chosen_handle}` (the role matched, but we can't push)"
7020 );
7021 }
7022 let start = Instant::now();
7023 let mut delivered = false;
7024 let mut last_err: Option<String> = None;
7025 let mut via_scope: Option<String> = None;
7026 for ep in &endpoints {
7027 match crate::relay_client::post_event_to_endpoint(ep, &signed) {
7032 Ok(_) => {
7033 delivered = true;
7034 via_scope = Some(
7035 match ep.scope {
7036 crate::endpoints::EndpointScope::Local => "local",
7037 crate::endpoints::EndpointScope::Lan => "lan",
7038 crate::endpoints::EndpointScope::Uds => "uds",
7039 crate::endpoints::EndpointScope::Federation => "federation",
7040 }
7041 .to_string(),
7042 );
7043 break;
7044 }
7045 Err(e) => last_err = Some(format!("{e:#}")),
7046 }
7047 }
7048 let rtt_ms = start.elapsed().as_millis() as u64;
7049
7050 let summary = json!({
7051 "role": role,
7052 "strategy": strategy,
7053 "routed_to": chosen_handle,
7054 "event_id": event_id,
7055 "delivered": delivered,
7056 "delivered_via": via_scope,
7057 "rtt_ms": rtt_ms,
7058 "candidates": candidates.iter().map(|(h, _)| h.clone()).collect::<Vec<_>>(),
7059 "error": last_err,
7060 });
7061
7062 if as_json {
7063 println!("{}", serde_json::to_string(&summary)?);
7064 } else if delivered {
7065 let via = via_scope.as_deref().unwrap_or("?");
7066 println!("wire mesh route: {role} → {chosen_handle} ({rtt_ms}ms, {via})");
7067 } else {
7068 let err = last_err.as_deref().unwrap_or("no endpoints reachable");
7069 bail!("delivery to `{chosen_handle}` failed: {err}");
7070 }
7071 Ok(())
7072}
7073
7074fn mesh_route_cursor_path() -> Result<std::path::PathBuf> {
7075 Ok(config::state_dir()?.join("mesh-route-cursor.json"))
7076}
7077
7078fn read_mesh_route_cursors(path: &std::path::Path) -> std::collections::BTreeMap<String, String> {
7079 std::fs::read(path)
7080 .ok()
7081 .and_then(|b| serde_json::from_slice(&b).ok())
7082 .unwrap_or_default()
7083}
7084
7085fn write_mesh_route_cursors(
7086 path: &std::path::Path,
7087 cursors: &std::collections::BTreeMap<String, String>,
7088) -> Result<()> {
7089 if let Some(parent) = path.parent() {
7090 std::fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
7091 }
7092 let body = serde_json::to_vec_pretty(cursors)?;
7093 std::fs::write(path, body).with_context(|| format!("writing {path:?}"))?;
7094 Ok(())
7095}
7096
7097fn cmd_mesh_role(action: MeshRoleAction) -> Result<()> {
7102 match action {
7103 MeshRoleAction::Set { role, json } => {
7104 validate_role_tag(&role)?;
7105 let new_profile =
7106 crate::pair_profile::write_profile_field("role", Value::String(role.clone()))?;
7107 if json {
7108 println!(
7109 "{}",
7110 serde_json::to_string(&json!({
7111 "role": role,
7112 "profile": new_profile,
7113 }))?
7114 );
7115 } else {
7116 println!("self role = {role} (signed into agent-card)");
7117 }
7118 }
7119 MeshRoleAction::Get { peer, json } => {
7120 let (who, role) = match peer.as_deref() {
7121 None => {
7122 let card = config::read_agent_card()?;
7123 let role = card
7124 .get("profile")
7125 .and_then(|p| p.get("role"))
7126 .and_then(Value::as_str)
7127 .map(str::to_string);
7128 let who = card
7129 .get("did")
7130 .and_then(Value::as_str)
7131 .map(|d| crate::agent_card::display_handle_from_did(d).to_string())
7132 .unwrap_or_else(|| "self".to_string());
7133 (who, role)
7134 }
7135 Some(handle) => {
7136 let bare = crate::agent_card::bare_handle(handle).to_string();
7137 let trust = config::read_trust()?;
7138 let role = trust
7139 .get("agents")
7140 .and_then(|a| a.get(&bare))
7141 .and_then(|a| a.get("card"))
7142 .and_then(|c| c.get("profile"))
7143 .and_then(|p| p.get("role"))
7144 .and_then(Value::as_str)
7145 .map(str::to_string);
7146 (bare, role)
7147 }
7148 };
7149 if json {
7150 println!(
7151 "{}",
7152 serde_json::to_string(&json!({
7153 "handle": who,
7154 "role": role,
7155 }))?
7156 );
7157 } else {
7158 match role {
7159 Some(r) => println!("{who}: {r}"),
7160 None => println!("{who}: (unset)"),
7161 }
7162 }
7163 }
7164 MeshRoleAction::List { json } => {
7165 let mut self_did: Option<String> = None;
7166 if let Ok(card) = config::read_agent_card() {
7167 self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
7168 }
7169 let sessions = crate::session::list_sessions()?;
7170 let mut rows: Vec<Value> = Vec::new();
7171 for s in &sessions {
7172 let card_path = s
7173 .home_dir
7174 .join("config")
7175 .join("wire")
7176 .join("agent-card.json");
7177 let role = std::fs::read(&card_path)
7178 .ok()
7179 .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
7180 .and_then(|c| {
7181 c.get("profile")
7182 .and_then(|p| p.get("role"))
7183 .and_then(Value::as_str)
7184 .map(str::to_string)
7185 });
7186 let is_self = match (&self_did, &s.did) {
7187 (Some(a), Some(b)) => a == b,
7188 _ => false,
7189 };
7190 rows.push(json!({
7191 "name": s.name,
7192 "handle": s.handle,
7193 "role": role,
7194 "self": is_self,
7195 }));
7196 }
7197 rows.sort_by(|a, b| {
7198 a["name"]
7199 .as_str()
7200 .unwrap_or("")
7201 .cmp(b["name"].as_str().unwrap_or(""))
7202 });
7203 if json {
7204 println!("{}", serde_json::to_string(&json!({"sessions": rows}))?);
7205 } else if rows.is_empty() {
7206 println!("no sister sessions on this machine.");
7207 } else {
7208 println!("SISTER ROLES (this machine):");
7209 for r in &rows {
7210 let name = r["name"].as_str().unwrap_or("?");
7211 let role = r["role"].as_str().unwrap_or("(unset)");
7212 let marker = if r["self"].as_bool().unwrap_or(false) {
7213 " ← you"
7214 } else {
7215 ""
7216 };
7217 println!(" {name:<24} {role}{marker}");
7218 }
7219 }
7220 }
7221 MeshRoleAction::Clear { json } => {
7222 let new_profile = crate::pair_profile::write_profile_field("role", Value::Null)?;
7223 if json {
7224 println!(
7225 "{}",
7226 serde_json::to_string(&json!({
7227 "cleared": true,
7228 "profile": new_profile,
7229 }))?
7230 );
7231 } else {
7232 println!("self role cleared");
7233 }
7234 }
7235 }
7236 Ok(())
7237}
7238
7239fn validate_role_tag(role: &str) -> Result<()> {
7244 if role.is_empty() {
7245 bail!("role must not be empty (use `wire mesh role --clear` to unset)");
7246 }
7247 if role.len() > 32 {
7248 bail!("role too long ({} chars; max 32)", role.len());
7249 }
7250 for c in role.chars() {
7251 if !(c.is_ascii_alphanumeric() || c == '-' || c == '_') {
7252 bail!("role contains illegal char {c:?} (allowed: A-Z a-z 0-9 - _)");
7253 }
7254 }
7255 Ok(())
7256}
7257
7258fn cmd_mesh_broadcast(
7278 kind: &str,
7279 scope_str: &str,
7280 exclude: &[String],
7281 _noreply: bool,
7282 body_arg: &str,
7283 as_json: bool,
7284) -> Result<()> {
7285 use std::time::Instant;
7286
7287 if !config::is_initialized()? {
7288 bail!("not initialized — run `wire init <handle>` first");
7289 }
7290
7291 let scope = match scope_str {
7292 "local" => crate::endpoints::EndpointScope::Local,
7293 "federation" => crate::endpoints::EndpointScope::Federation,
7294 "both" => {
7295 crate::endpoints::EndpointScope::Local
7299 }
7300 other => bail!("unknown scope `{other}` — use local | federation | both"),
7301 };
7302 let any_scope = scope_str == "both";
7303
7304 let state = config::read_relay_state()?;
7305 let peers = state["peers"].as_object().cloned().unwrap_or_default();
7306 if peers.is_empty() {
7307 bail!("no peers pinned — run `wire accept <invite-url>` or `wire pair-accept` first");
7308 }
7309
7310 let exclude_set: std::collections::HashSet<&str> = exclude.iter().map(String::as_str).collect();
7311
7312 struct Target {
7316 handle: String,
7317 endpoints: Vec<crate::endpoints::Endpoint>,
7318 }
7319 let mut targets: Vec<Target> = Vec::new();
7320 let mut skipped_wrong_scope: Vec<String> = Vec::new();
7321 let mut skipped_excluded: Vec<String> = Vec::new();
7322 for handle in peers.keys() {
7323 if exclude_set.contains(handle.as_str()) {
7324 skipped_excluded.push(handle.clone());
7325 continue;
7326 }
7327 let ordered = crate::endpoints::peer_endpoints_in_priority_order(&state, handle);
7328 let filtered: Vec<crate::endpoints::Endpoint> = ordered
7329 .into_iter()
7330 .filter(|ep| any_scope || ep.scope == scope)
7331 .collect();
7332 if filtered.is_empty() {
7333 skipped_wrong_scope.push(handle.clone());
7334 continue;
7335 }
7336 targets.push(Target {
7337 handle: handle.clone(),
7338 endpoints: filtered,
7339 });
7340 }
7341
7342 if targets.is_empty() {
7343 bail!(
7344 "no peers matched scope=`{scope_str}` after exclude filter ({} excluded, {} wrong-scope)",
7345 skipped_excluded.len(),
7346 skipped_wrong_scope.len()
7347 );
7348 }
7349
7350 let sk_seed = config::read_private_key()?;
7352 let card = config::read_agent_card()?;
7353 let did = card
7354 .get("did")
7355 .and_then(Value::as_str)
7356 .ok_or_else(|| anyhow!("agent-card missing did"))?
7357 .to_string();
7358 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
7359 let pk_b64 = card
7360 .get("verify_keys")
7361 .and_then(Value::as_object)
7362 .and_then(|m| m.values().next())
7363 .and_then(|v| v.get("key"))
7364 .and_then(Value::as_str)
7365 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
7366 let pk_bytes = crate::signing::b64decode(pk_b64)?;
7367
7368 let body_value: Value = if body_arg == "-" {
7369 use std::io::Read;
7370 let mut raw = String::new();
7371 std::io::stdin()
7372 .read_to_string(&mut raw)
7373 .with_context(|| "reading body from stdin")?;
7374 serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
7375 } else if let Some(path) = body_arg.strip_prefix('@') {
7376 let raw =
7377 std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
7378 serde_json::from_str(&raw).unwrap_or(Value::String(raw))
7379 } else {
7380 Value::String(body_arg.to_string())
7381 };
7382
7383 let kind_id = parse_kind(kind)?;
7384 let now_iso = time::OffsetDateTime::now_utc()
7385 .format(&time::format_description::well_known::Rfc3339)
7386 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
7387
7388 let broadcast_id = generate_broadcast_id();
7389 let target_count = targets.len();
7390
7391 let mut signed_per_peer: Vec<(String, Vec<crate::endpoints::Endpoint>, Value, String)> =
7395 Vec::with_capacity(targets.len());
7396 for t in &targets {
7397 let body = json!({
7398 "content": body_value,
7399 "broadcast_id": broadcast_id,
7400 "broadcast_target_count": target_count,
7401 });
7402 let event = json!({
7403 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
7404 "timestamp": now_iso,
7405 "from": did,
7406 "to": format!("did:wire:{}", t.handle),
7407 "type": kind,
7408 "kind": kind_id,
7409 "body": body,
7410 });
7411 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)
7412 .map_err(|e| anyhow!("sign_message_v31 failed for `{}`: {e:?}", t.handle))?;
7413 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
7414 signed_per_peer.push((t.handle.clone(), t.endpoints.clone(), signed, event_id));
7415 }
7416
7417 for (peer, _, signed, _) in &signed_per_peer {
7421 let line = serde_json::to_vec(signed)?;
7422 config::append_outbox_record(peer, &line)?;
7423 }
7424
7425 use std::sync::mpsc;
7429 let (tx, rx) = mpsc::channel::<Value>();
7430 std::thread::scope(|s| {
7431 for (peer, endpoints, signed, event_id) in &signed_per_peer {
7432 let tx = tx.clone();
7433 let peer = peer.clone();
7434 let event_id = event_id.clone();
7435 let endpoints = endpoints.clone();
7436 let signed = signed.clone();
7437 s.spawn(move || {
7438 let start = Instant::now();
7439 let mut delivered = false;
7440 let mut last_err: Option<String> = None;
7441 let mut delivered_via: Option<String> = None;
7442 for ep in &endpoints {
7443 match crate::relay_client::post_event_to_endpoint(ep, &signed) {
7448 Ok(_) => {
7449 delivered = true;
7450 delivered_via = Some(
7451 match ep.scope {
7452 crate::endpoints::EndpointScope::Local => "local",
7453 crate::endpoints::EndpointScope::Lan => "lan",
7454 crate::endpoints::EndpointScope::Uds => "uds",
7455 crate::endpoints::EndpointScope::Federation => "federation",
7456 }
7457 .to_string(),
7458 );
7459 break;
7460 }
7461 Err(e) => last_err = Some(format!("{e:#}")),
7462 }
7463 }
7464 let rtt_ms = start.elapsed().as_millis() as u64;
7465 let _ = tx.send(json!({
7466 "peer": peer,
7467 "event_id": event_id,
7468 "delivered": delivered,
7469 "delivered_via": delivered_via,
7470 "rtt_ms": rtt_ms,
7471 "error": last_err,
7472 }));
7473 });
7474 }
7475 });
7476 drop(tx);
7477
7478 let mut results: Vec<Value> = rx.iter().collect();
7479 results.sort_by(|a, b| {
7480 a["peer"]
7481 .as_str()
7482 .unwrap_or("")
7483 .cmp(b["peer"].as_str().unwrap_or(""))
7484 });
7485
7486 let delivered = results
7487 .iter()
7488 .filter(|r| r["delivered"].as_bool().unwrap_or(false))
7489 .count();
7490 let failed = results.len() - delivered;
7491
7492 let summary = json!({
7493 "broadcast_id": broadcast_id,
7494 "kind": kind,
7495 "scope": scope_str,
7496 "target_count": target_count,
7497 "delivered": delivered,
7498 "failed": failed,
7499 "skipped_excluded": skipped_excluded,
7500 "skipped_wrong_scope": skipped_wrong_scope,
7501 "results": results,
7502 });
7503
7504 if as_json {
7505 println!("{}", serde_json::to_string(&summary)?);
7506 return Ok(());
7507 }
7508
7509 println!("wire mesh broadcast: scope={scope_str} → {target_count} pinned peer(s)");
7510 for r in &results {
7511 let peer = r["peer"].as_str().unwrap_or("?");
7512 let delivered = r["delivered"].as_bool().unwrap_or(false);
7513 let rtt = r["rtt_ms"].as_u64().unwrap_or(0);
7514 let via = r["delivered_via"].as_str().unwrap_or("");
7515 if delivered {
7516 println!(" {peer:<24} ✓ delivered ({rtt}ms, {via})");
7517 } else {
7518 let err = r["error"].as_str().unwrap_or("?");
7519 println!(" {peer:<24} ✗ failed — {err}");
7520 }
7521 }
7522 if !skipped_excluded.is_empty() {
7523 println!(" excluded: {}", skipped_excluded.join(", "));
7524 }
7525 if !skipped_wrong_scope.is_empty() {
7526 println!(
7527 " skipped (wrong scope): {}",
7528 skipped_wrong_scope.join(", ")
7529 );
7530 }
7531 println!("broadcast_id: {broadcast_id}");
7532 Ok(())
7533}
7534
7535fn generate_broadcast_id() -> String {
7539 use rand::RngCore;
7540 let mut buf = [0u8; 16];
7541 rand::thread_rng().fill_bytes(&mut buf);
7542 let h = hex::encode(buf);
7543 format!(
7544 "{}-{}-{}-{}-{}",
7545 &h[0..8],
7546 &h[8..12],
7547 &h[12..16],
7548 &h[16..20],
7549 &h[20..32],
7550 )
7551}
7552
7553fn cmd_session(cmd: SessionCommand) -> Result<()> {
7554 match cmd {
7555 SessionCommand::New {
7556 name,
7557 relay,
7558 with_local,
7559 local_relay,
7560 with_lan,
7561 lan_relay,
7562 with_uds,
7563 uds_socket,
7564 no_daemon,
7565 local_only,
7566 json,
7567 } => cmd_session_new(
7568 name.as_deref(),
7569 &relay,
7570 with_local,
7571 &local_relay,
7572 with_lan,
7573 lan_relay.as_deref(),
7574 with_uds,
7575 uds_socket.as_deref(),
7576 no_daemon,
7577 local_only,
7578 json,
7579 ),
7580 SessionCommand::List { json } => cmd_session_list(json),
7581 SessionCommand::ListLocal { json } => cmd_session_list_local(json),
7582 SessionCommand::PairAllLocal {
7583 settle_secs,
7584 federation_relay,
7585 json,
7586 } => cmd_session_pair_all_local(settle_secs, &federation_relay, json),
7587 SessionCommand::MeshStatus { stale_secs, json } => {
7588 cmd_session_mesh_status(stale_secs, json)
7589 }
7590 SessionCommand::Env { name, json } => cmd_session_env(name.as_deref(), json),
7591 SessionCommand::Current { json } => cmd_session_current(json),
7592 SessionCommand::Bind { name, json } => cmd_session_bind(name.as_deref(), json),
7593 SessionCommand::Destroy { name, force, json } => cmd_session_destroy(&name, force, json),
7594 }
7595}
7596
7597fn cmd_session_bind(name_arg: Option<&str>, json: bool) -> Result<()> {
7598 let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
7599 let cwd_str = cwd.to_string_lossy().into_owned();
7600
7601 let resolved_name = match name_arg {
7602 Some(n) => crate::session::sanitize_name(n),
7603 None => crate::session::sanitize_name(
7604 cwd.file_name()
7605 .and_then(|s| s.to_str())
7606 .ok_or_else(|| anyhow!("cwd has no basename to derive session name from"))?,
7607 ),
7608 };
7609
7610 let session_home = crate::session::session_dir(&resolved_name)?;
7611 if !session_home.exists() {
7612 bail!(
7613 "session `{resolved_name}` does not exist (looked at {}). Create it first with `wire session new {resolved_name}` or pass an existing name.",
7614 session_home.display()
7615 );
7616 }
7617
7618 let prior = crate::session::read_registry()
7619 .ok()
7620 .and_then(|r| r.by_cwd.get(&cwd_str).cloned());
7621 if prior.as_deref() == Some(resolved_name.as_str()) {
7622 if json {
7623 println!(
7624 "{}",
7625 serde_json::to_string(&json!({
7626 "cwd": cwd_str,
7627 "session": resolved_name,
7628 "changed": false,
7629 }))?
7630 );
7631 } else {
7632 println!("cwd `{cwd_str}` already bound to session `{resolved_name}` (no change)");
7633 }
7634 return Ok(());
7635 }
7636 if let Some(prior_name) = &prior {
7637 eprintln!(
7638 "wire session bind: cwd `{cwd_str}` was bound to `{prior_name}`; overwriting with `{resolved_name}`."
7639 );
7640 }
7641
7642 crate::session::update_registry(|reg| {
7643 reg.by_cwd.insert(cwd_str.clone(), resolved_name.clone());
7644 Ok(())
7645 })?;
7646
7647 if json {
7648 println!(
7649 "{}",
7650 serde_json::to_string(&json!({
7651 "cwd": cwd_str,
7652 "session": resolved_name,
7653 "changed": true,
7654 "previous": prior,
7655 }))?
7656 );
7657 } else {
7658 println!("bound cwd `{cwd_str}` → session `{resolved_name}`");
7659 println!("(next `wire` invocation from this cwd will auto-detect into this session)");
7660 }
7661 Ok(())
7662}
7663
7664fn resolve_session_name(name: Option<&str>) -> Result<String> {
7665 if let Some(n) = name {
7666 return Ok(crate::session::sanitize_name(n));
7667 }
7668 let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
7669 let registry = crate::session::read_registry().unwrap_or_default();
7670 Ok(crate::session::derive_name_from_cwd(&cwd, ®istry))
7671}
7672
7673#[allow(clippy::too_many_arguments)] fn cmd_session_new(
7677 name_arg: Option<&str>,
7678 relay: &str,
7679 with_local: bool,
7680 local_relay: &str,
7681 with_lan: bool,
7682 lan_relay: Option<&str>,
7683 with_uds: bool,
7684 uds_socket: Option<&std::path::Path>,
7685 no_daemon: bool,
7686 local_only: bool,
7687 as_json: bool,
7688) -> Result<()> {
7689 let with_local = with_local || local_only;
7692 if with_lan && lan_relay.is_none() {
7694 bail!("--with-lan requires --lan-relay <url> (e.g. http://192.168.1.50:8771)");
7695 }
7696 if with_uds && uds_socket.is_none() {
7698 bail!("--with-uds requires --uds-socket <path> (e.g. /tmp/wire.sock)");
7699 }
7700 let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
7701 let mut registry = crate::session::read_registry().unwrap_or_default();
7702 let name = match name_arg {
7703 Some(n) => crate::session::sanitize_name(n),
7704 None => crate::session::derive_name_from_cwd(&cwd, ®istry),
7705 };
7706 let session_home = crate::session::session_dir(&name)?;
7707
7708 let already_exists = session_home.exists()
7709 && session_home
7710 .join("config")
7711 .join("wire")
7712 .join("agent-card.json")
7713 .exists();
7714 if already_exists {
7715 registry
7719 .by_cwd
7720 .insert(cwd.to_string_lossy().into_owned(), name.clone());
7721 crate::session::write_registry(®istry)?;
7722 let info = render_session_info(&name, &session_home, &cwd)?;
7723 emit_session_new_result(&info, "already_exists", as_json)?;
7724 if !no_daemon {
7725 ensure_session_daemon(&session_home)?;
7726 }
7727 return Ok(());
7728 }
7729
7730 std::fs::create_dir_all(&session_home)
7731 .with_context(|| format!("creating session dir {session_home:?}"))?;
7732
7733 let init_args: Vec<&str> = if local_only {
7742 vec!["init", &name, "--offline"]
7743 } else {
7744 vec!["init", &name, "--relay", relay]
7745 };
7746 let init_status = run_wire_with_home(&session_home, &init_args)?;
7747 if !init_status.success() {
7748 let how = if local_only {
7749 format!("`wire init {name}` (local-only)")
7750 } else {
7751 format!("`wire init {name} --relay {relay}`")
7752 };
7753 bail!("{how} failed inside session dir {session_home:?}");
7754 }
7755
7756 let effective_handle = if local_only {
7761 name.clone()
7762 } else {
7763 let mut claim_attempt = 0u32;
7764 let mut effective = name.clone();
7765 loop {
7766 claim_attempt += 1;
7767 let status =
7768 run_wire_with_home(&session_home, &["claim", &effective, "--relay", relay])?;
7769 if status.success() {
7770 break;
7771 }
7772 if claim_attempt >= 5 {
7773 bail!(
7774 "5 failed attempts to claim a handle on {relay} for session {name}. \
7775 Try `wire session destroy {name} --force` and re-run with a different name, \
7776 or use `--local-only` if you don't need a federation address."
7777 );
7778 }
7779 let attempt_path = cwd.join(format!("__attempt_{claim_attempt}"));
7780 let suffix = crate::session::derive_name_from_cwd(&attempt_path, ®istry);
7781 let token = suffix
7782 .rsplit('-')
7783 .next()
7784 .filter(|t| t.len() == 4)
7785 .map(str::to_string)
7786 .unwrap_or_else(|| format!("{claim_attempt}"));
7787 effective = format!("{name}-{token}");
7788 }
7789 effective
7790 };
7791
7792 registry
7795 .by_cwd
7796 .insert(cwd.to_string_lossy().into_owned(), name.clone());
7797 crate::session::write_registry(®istry)?;
7798
7799 if with_local {
7810 try_allocate_local_slot(&session_home, &effective_handle, relay, local_relay);
7811 if local_only {
7812 let relay_state_path = session_home.join("config").join("wire").join("relay.json");
7817 let state: Value = std::fs::read(&relay_state_path)
7818 .ok()
7819 .and_then(|b| serde_json::from_slice(&b).ok())
7820 .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
7821 let endpoints = crate::endpoints::self_endpoints(&state);
7822 let has_local = endpoints
7823 .iter()
7824 .any(|e| e.scope == crate::endpoints::EndpointScope::Local);
7825 if !has_local {
7826 bail!(
7827 "--local-only requested but local-relay probe at {local_relay} failed — \
7828 ensure the local relay is running (`wire service install --local-relay`), \
7829 then re-run `wire session new {name} --local-only`."
7830 );
7831 }
7832 }
7833 }
7834
7835 if with_lan && let Some(lan_url) = lan_relay {
7839 try_allocate_lan_slot(&session_home, &effective_handle, lan_url);
7840 }
7841 if with_uds && let Some(socket_path) = uds_socket {
7843 try_allocate_uds_slot(&session_home, &effective_handle, socket_path);
7844 }
7845
7846 if !no_daemon {
7847 ensure_session_daemon(&session_home)?;
7848 }
7849
7850 let info = render_session_info(&name, &session_home, &cwd)?;
7851 emit_session_new_result(&info, "created", as_json)
7852}
7853
7854#[cfg(unix)]
7864fn try_allocate_uds_slot(
7865 session_home: &std::path::Path,
7866 handle: &str,
7867 uds_socket: &std::path::Path,
7868) {
7869 let healthz = match crate::relay_client::uds_request(uds_socket, "GET", "/healthz", &[], b"") {
7872 Ok((200, _)) => true,
7873 Ok((status, body)) => {
7874 eprintln!(
7875 "wire session new: UDS relay probe at {uds_socket:?} returned {status} ({}) — not publishing UDS endpoint",
7876 String::from_utf8_lossy(&body)
7877 );
7878 return;
7879 }
7880 Err(e) => {
7881 eprintln!(
7882 "wire session new: UDS relay at {uds_socket:?} unreachable ({e:#}) — \
7883 not publishing UDS endpoint. Start one with `wire relay-server --uds <path>`."
7884 );
7885 return;
7886 }
7887 };
7888 if !healthz {
7889 return;
7890 }
7891
7892 let alloc_body = serde_json::json!({"handle": handle}).to_string();
7894 let (status, body) = match crate::relay_client::uds_request(
7895 uds_socket,
7896 "POST",
7897 "/v1/slot/allocate",
7898 &[("Content-Type", "application/json")],
7899 alloc_body.as_bytes(),
7900 ) {
7901 Ok(r) => r,
7902 Err(e) => {
7903 eprintln!(
7904 "wire session new: UDS relay slot allocation request failed: {e:#} — not publishing UDS endpoint"
7905 );
7906 return;
7907 }
7908 };
7909 if status >= 300 {
7910 eprintln!(
7911 "wire session new: UDS relay slot allocation returned {status} ({}) — not publishing UDS endpoint",
7912 String::from_utf8_lossy(&body)
7913 );
7914 return;
7915 }
7916 let alloc: crate::relay_client::AllocateResponse = match serde_json::from_slice(&body) {
7917 Ok(a) => a,
7918 Err(e) => {
7919 eprintln!("wire session new: UDS relay returned unparseable allocate response: {e:#}");
7920 return;
7921 }
7922 };
7923
7924 let state_path = session_home.join("config").join("wire").join("relay.json");
7925 let mut state: serde_json::Value = std::fs::read(&state_path)
7926 .ok()
7927 .and_then(|b| serde_json::from_slice(&b).ok())
7928 .unwrap_or_else(|| serde_json::json!({}));
7929
7930 let mut endpoints: Vec<crate::endpoints::Endpoint> = state
7931 .get("self")
7932 .and_then(|s| s.get("endpoints"))
7933 .and_then(|e| e.as_array())
7934 .map(|arr| {
7935 arr.iter()
7936 .filter_map(|v| {
7937 serde_json::from_value::<crate::endpoints::Endpoint>(v.clone()).ok()
7938 })
7939 .collect()
7940 })
7941 .unwrap_or_default();
7942 endpoints.push(crate::endpoints::Endpoint::uds(
7943 format!("unix://{}", uds_socket.display()),
7944 alloc.slot_id.clone(),
7945 alloc.slot_token.clone(),
7946 ));
7947
7948 let self_obj = state
7949 .as_object_mut()
7950 .expect("relay_state root is an object")
7951 .entry("self")
7952 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
7953 if !self_obj.is_object() {
7954 *self_obj = serde_json::Value::Object(serde_json::Map::new());
7955 }
7956 if let Some(obj) = self_obj.as_object_mut() {
7957 obj.insert(
7958 "endpoints".into(),
7959 serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
7960 );
7961 }
7962 if let Err(e) = std::fs::write(
7963 &state_path,
7964 serde_json::to_vec_pretty(&state).expect("relay_state serializable"),
7965 ) {
7966 eprintln!("wire session new: failed to write {state_path:?}: {e}");
7967 return;
7968 }
7969 eprintln!(
7970 "wire session new: UDS slot allocated on unix://{} (slot_id={}) — sister sessions will see this endpoint in your agent-card",
7971 uds_socket.display(),
7972 alloc.slot_id
7973 );
7974}
7975
7976#[cfg(not(unix))]
7977fn try_allocate_uds_slot(
7978 _session_home: &std::path::Path,
7979 _handle: &str,
7980 _uds_socket: &std::path::Path,
7981) {
7982 eprintln!(
7983 "wire session new: --with-uds is Unix-only (Windows lacks AF_UNIX in tokio/reqwest); ignoring"
7984 );
7985}
7986
7987fn try_allocate_lan_slot(session_home: &std::path::Path, handle: &str, lan_relay: &str) {
7997 let probe = match crate::relay_client::build_blocking_client(Some(
7998 std::time::Duration::from_millis(500),
7999 )) {
8000 Ok(c) => c,
8001 Err(e) => {
8002 eprintln!("wire session new: cannot build LAN probe client for {lan_relay}: {e:#}");
8003 return;
8004 }
8005 };
8006 let healthz_url = format!("{}/healthz", lan_relay.trim_end_matches('/'));
8007 match probe.get(&healthz_url).send() {
8008 Ok(resp) if resp.status().is_success() => {}
8009 Ok(resp) => {
8010 eprintln!(
8011 "wire session new: LAN relay probe at {healthz_url} returned {} — not publishing LAN endpoint",
8012 resp.status()
8013 );
8014 return;
8015 }
8016 Err(e) => {
8017 eprintln!(
8018 "wire session new: LAN relay at {lan_relay} unreachable ({}) — not publishing LAN endpoint. \
8019 Start one on the LAN-bound interface with `wire relay-server --bind <LAN-IP>:8771 --local-only`.",
8020 crate::relay_client::format_transport_error(&anyhow::Error::new(e))
8021 );
8022 return;
8023 }
8024 };
8025
8026 let lan_client = crate::relay_client::RelayClient::new(lan_relay);
8027 let alloc = match lan_client.allocate_slot(Some(handle)) {
8028 Ok(a) => a,
8029 Err(e) => {
8030 eprintln!(
8031 "wire session new: LAN relay slot allocation failed: {e:#} — not publishing LAN endpoint"
8032 );
8033 return;
8034 }
8035 };
8036
8037 let state_path = session_home.join("config").join("wire").join("relay.json");
8038 let mut state: serde_json::Value = std::fs::read(&state_path)
8039 .ok()
8040 .and_then(|b| serde_json::from_slice(&b).ok())
8041 .unwrap_or_else(|| serde_json::json!({}));
8042
8043 let mut endpoints: Vec<crate::endpoints::Endpoint> = state
8046 .get("self")
8047 .and_then(|s| s.get("endpoints"))
8048 .and_then(|e| e.as_array())
8049 .map(|arr| {
8050 arr.iter()
8051 .filter_map(|v| {
8052 serde_json::from_value::<crate::endpoints::Endpoint>(v.clone()).ok()
8053 })
8054 .collect()
8055 })
8056 .unwrap_or_default();
8057 endpoints.push(crate::endpoints::Endpoint::lan(
8058 lan_relay.trim_end_matches('/').to_string(),
8059 alloc.slot_id.clone(),
8060 alloc.slot_token.clone(),
8061 ));
8062
8063 let self_obj = state
8064 .as_object_mut()
8065 .expect("relay_state root is an object")
8066 .entry("self")
8067 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
8068 if !self_obj.is_object() {
8069 *self_obj = serde_json::Value::Object(serde_json::Map::new());
8070 }
8071 if let Some(obj) = self_obj.as_object_mut() {
8072 obj.insert(
8073 "endpoints".into(),
8074 serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
8075 );
8076 }
8077 if let Err(e) = std::fs::write(
8078 &state_path,
8079 serde_json::to_vec_pretty(&state).expect("relay_state serializable"),
8080 ) {
8081 eprintln!("wire session new: failed to write {state_path:?}: {e}");
8082 return;
8083 }
8084 eprintln!(
8085 "wire session new: LAN slot allocated on {lan_relay} (slot_id={}) — peers will see this endpoint in your agent-card",
8086 alloc.slot_id
8087 );
8088}
8089
8090fn try_allocate_local_slot(
8098 session_home: &std::path::Path,
8099 handle: &str,
8100 _federation_relay: &str,
8101 local_relay: &str,
8102) {
8103 let probe = match crate::relay_client::build_blocking_client(Some(
8106 std::time::Duration::from_millis(500),
8107 )) {
8108 Ok(c) => c,
8109 Err(e) => {
8110 eprintln!("wire session new: cannot build probe client for {local_relay}: {e:#}");
8111 return;
8112 }
8113 };
8114 let healthz_url = format!("{}/healthz", local_relay.trim_end_matches('/'));
8115 match probe.get(&healthz_url).send() {
8116 Ok(resp) if resp.status().is_success() => {}
8117 Ok(resp) => {
8118 eprintln!(
8119 "wire session new: local relay probe at {healthz_url} returned {} — staying federation-only",
8120 resp.status()
8121 );
8122 return;
8123 }
8124 Err(e) => {
8125 eprintln!(
8126 "wire session new: local relay at {local_relay} unreachable ({}) — staying federation-only. \
8127 Start one with `wire relay-server --bind 127.0.0.1:8771 --local-only`.",
8128 crate::relay_client::format_transport_error(&anyhow::Error::new(e))
8129 );
8130 return;
8131 }
8132 };
8133
8134 let local_client = crate::relay_client::RelayClient::new(local_relay);
8136 let alloc = match local_client.allocate_slot(Some(handle)) {
8137 Ok(a) => a,
8138 Err(e) => {
8139 eprintln!(
8140 "wire session new: local relay slot allocation failed: {e:#} — staying federation-only"
8141 );
8142 return;
8143 }
8144 };
8145
8146 let state_path = session_home.join("config").join("wire").join("relay.json");
8161 let mut state: serde_json::Value = std::fs::read(&state_path)
8162 .ok()
8163 .and_then(|b| serde_json::from_slice(&b).ok())
8164 .unwrap_or_else(|| serde_json::json!({}));
8165 let fed_endpoint = state.get("self").and_then(|s| {
8168 let url = s.get("relay_url").and_then(serde_json::Value::as_str)?;
8169 let slot_id = s.get("slot_id").and_then(serde_json::Value::as_str)?;
8170 let slot_token = s.get("slot_token").and_then(serde_json::Value::as_str)?;
8171 Some(crate::endpoints::Endpoint::federation(
8172 url.to_string(),
8173 slot_id.to_string(),
8174 slot_token.to_string(),
8175 ))
8176 });
8177
8178 let local_endpoint = crate::endpoints::Endpoint::local(
8179 local_relay.trim_end_matches('/').to_string(),
8180 alloc.slot_id.clone(),
8181 alloc.slot_token.clone(),
8182 );
8183
8184 let mut endpoints: Vec<crate::endpoints::Endpoint> = Vec::new();
8185 if let Some(f) = fed_endpoint.clone() {
8186 endpoints.push(f);
8187 }
8188 endpoints.push(local_endpoint);
8189
8190 let (legacy_relay, legacy_slot_id, legacy_slot_token) = match fed_endpoint.clone() {
8200 Some(f) => (f.relay_url, f.slot_id, f.slot_token),
8201 None => (
8202 local_relay.trim_end_matches('/').to_string(),
8203 alloc.slot_id.clone(),
8204 alloc.slot_token.clone(),
8205 ),
8206 };
8207 let self_obj = state
8208 .as_object_mut()
8209 .expect("relay_state root is an object")
8210 .entry("self")
8211 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
8212 if !self_obj.is_object() {
8215 *self_obj = serde_json::Value::Object(serde_json::Map::new());
8216 }
8217 if let Some(obj) = self_obj.as_object_mut() {
8218 obj.insert("relay_url".into(), serde_json::Value::String(legacy_relay));
8219 obj.insert("slot_id".into(), serde_json::Value::String(legacy_slot_id));
8220 obj.insert(
8221 "slot_token".into(),
8222 serde_json::Value::String(legacy_slot_token),
8223 );
8224 obj.insert(
8225 "endpoints".into(),
8226 serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
8227 );
8228 }
8229
8230 if let Err(e) = std::fs::write(
8231 &state_path,
8232 serde_json::to_vec_pretty(&state).unwrap_or_default(),
8233 ) {
8234 eprintln!(
8235 "wire session new: persisting dual-slot relay_state at {state_path:?} failed: {e}"
8236 );
8237 return;
8238 }
8239 eprintln!(
8240 "wire session new: local slot allocated on {local_relay} (slot_id={})",
8241 alloc.slot_id
8242 );
8243}
8244
8245fn render_session_info(
8246 name: &str,
8247 session_home: &std::path::Path,
8248 cwd: &std::path::Path,
8249) -> Result<serde_json::Value> {
8250 let card_path = session_home
8251 .join("config")
8252 .join("wire")
8253 .join("agent-card.json");
8254 let (did, handle) = if card_path.exists() {
8255 let card: Value = serde_json::from_slice(&std::fs::read(&card_path)?)?;
8256 let did = card
8257 .get("did")
8258 .and_then(Value::as_str)
8259 .unwrap_or("")
8260 .to_string();
8261 let handle = card
8262 .get("handle")
8263 .and_then(Value::as_str)
8264 .map(str::to_string)
8265 .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
8266 (did, handle)
8267 } else {
8268 (String::new(), String::new())
8269 };
8270 Ok(json!({
8271 "name": name,
8272 "home_dir": session_home.to_string_lossy(),
8273 "cwd": cwd.to_string_lossy(),
8274 "did": did,
8275 "handle": handle,
8276 "export": format!("export WIRE_HOME={}", session_home.to_string_lossy()),
8277 }))
8278}
8279
8280fn emit_session_new_result(info: &serde_json::Value, status: &str, as_json: bool) -> Result<()> {
8281 if as_json {
8282 let mut obj = info.clone();
8283 obj["status"] = json!(status);
8284 println!("{}", serde_json::to_string(&obj)?);
8285 } else {
8286 let name = info["name"].as_str().unwrap_or("?");
8287 let handle = info["handle"].as_str().unwrap_or("?");
8288 let home = info["home_dir"].as_str().unwrap_or("?");
8289 let did = info["did"].as_str().unwrap_or("?");
8290 let export = info["export"].as_str().unwrap_or("?");
8291 let prefix = if status == "already_exists" {
8292 "session already exists (re-registered cwd)"
8293 } else {
8294 "session created"
8295 };
8296 println!(
8297 "{prefix}\n name: {name}\n handle: {handle}\n did: {did}\n home: {home}\n\nactivate with:\n {export}"
8298 );
8299 }
8300 Ok(())
8301}
8302
8303fn run_wire_with_home(
8304 session_home: &std::path::Path,
8305 args: &[&str],
8306) -> Result<std::process::ExitStatus> {
8307 let bin = std::env::current_exe().with_context(|| "locating self exe")?;
8308 let status = std::process::Command::new(&bin)
8309 .env("WIRE_HOME", session_home)
8310 .env_remove("RUST_LOG")
8311 .env("WIRE_AUTO_INIT", "0")
8314 .args(args)
8315 .status()
8316 .with_context(|| format!("spawning `wire {}`", args.join(" ")))?;
8317 Ok(status)
8318}
8319
8320pub fn maybe_auto_init_cwd_session(label: &str) {
8339 if std::env::var("WIRE_HOME").is_ok() {
8340 return; }
8342 if std::env::var("WIRE_AUTO_INIT").as_deref() == Ok("0") {
8343 return; }
8345 let cwd = match std::env::current_dir() {
8346 Ok(c) => c,
8347 Err(_) => return,
8348 };
8349 if crate::session::detect_session_wire_home(&cwd).is_some() {
8352 return;
8353 }
8354
8355 use fs2::FileExt;
8372 let sessions_root = match crate::session::sessions_root() {
8373 Ok(r) => r,
8374 Err(_) => return,
8375 };
8376 if let Err(e) = std::fs::create_dir_all(&sessions_root) {
8377 eprintln!("wire {label}: auto-init: failed to create sessions root {sessions_root:?}: {e}");
8378 return;
8379 }
8380 let lock_path = sessions_root.join(".auto-init.lock");
8381 let lock_file = match std::fs::OpenOptions::new()
8382 .create(true)
8383 .truncate(false)
8384 .read(true)
8385 .write(true)
8386 .open(&lock_path)
8387 {
8388 Ok(f) => f,
8389 Err(e) => {
8390 eprintln!(
8391 "wire {label}: auto-init: cannot open lockfile {lock_path:?}: {e} — falling back to default identity"
8392 );
8393 return;
8394 }
8395 };
8396 if let Err(e) = lock_file.lock_exclusive() {
8397 eprintln!(
8398 "wire {label}: auto-init: flock {lock_path:?} failed: {e} — falling back to default identity"
8399 );
8400 return;
8401 }
8402 let registry = crate::session::read_registry().unwrap_or_default();
8407 let name = crate::session::derive_name_from_cwd(&cwd, ®istry);
8408 let session_home = match crate::session::session_dir(&name) {
8409 Ok(h) => h,
8410 Err(_) => {
8411 let _ = fs2::FileExt::unlock(&lock_file);
8412 return;
8413 }
8414 };
8415 let agent_card_path = session_home
8416 .join("config")
8417 .join("wire")
8418 .join("agent-card.json");
8419 let needs_init = !agent_card_path.exists();
8420
8421 if needs_init {
8422 if let Err(e) = std::fs::create_dir_all(&session_home) {
8423 eprintln!(
8424 "wire {label}: auto-init: failed to create session dir {session_home:?}: {e}"
8425 );
8426 let _ = fs2::FileExt::unlock(&lock_file);
8427 return;
8428 }
8429 match run_wire_with_home(&session_home, &["init", &name, "--offline"]) {
8434 Ok(status) if status.success() => {}
8435 Ok(status) => {
8436 eprintln!(
8437 "wire {label}: auto-init: `wire init {name}` exited non-zero ({status}) — falling back to default identity"
8438 );
8439 let _ = fs2::FileExt::unlock(&lock_file);
8440 return;
8441 }
8442 Err(e) => {
8443 eprintln!(
8444 "wire {label}: auto-init: failed to spawn `wire init {name}`: {e:#} — falling back to default identity"
8445 );
8446 let _ = fs2::FileExt::unlock(&lock_file);
8447 return;
8448 }
8449 }
8450 try_allocate_local_slot(
8457 &session_home,
8458 &name,
8459 "https://wireup.net",
8460 "http://127.0.0.1:8771",
8461 );
8462 } else {
8463 if std::env::var("WIRE_QUIET_AUTOSESSION").is_err() {
8467 eprintln!(
8468 "wire {label}: auto-init: session `{name}` already exists (concurrent mcp peer won the race) — adopting"
8469 );
8470 }
8471 }
8472 let cwd_key = cwd.to_string_lossy().into_owned();
8482 let name_for_reg = name.clone();
8483 if let Err(e) = crate::session::update_registry(|reg| {
8484 reg.by_cwd.insert(cwd_key, name_for_reg);
8485 Ok(())
8486 }) {
8487 eprintln!("wire {label}: auto-init: failed to update registry: {e:#}");
8488 }
8490 let _ = fs2::FileExt::unlock(&lock_file);
8493
8494 if std::env::var("WIRE_QUIET_AUTOSESSION").is_err() {
8495 eprintln!(
8496 "wire {label}: auto-init: created session `{name}` for cwd `{}` → WIRE_HOME=`{}`",
8497 cwd.display(),
8498 session_home.display()
8499 );
8500 }
8501 unsafe {
8504 std::env::set_var("WIRE_HOME", &session_home);
8505 }
8506}
8507
8508fn ensure_session_daemon(session_home: &std::path::Path) -> Result<()> {
8509 let pidfile = session_home.join("state").join("wire").join("daemon.pid");
8512 if pidfile.exists() {
8513 let bytes = std::fs::read(&pidfile).unwrap_or_default();
8514 let pid: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
8515 v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
8516 } else {
8517 String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
8518 };
8519 if let Some(p) = pid {
8520 let alive = {
8521 #[cfg(target_os = "linux")]
8522 {
8523 std::path::Path::new(&format!("/proc/{p}")).exists()
8524 }
8525 #[cfg(not(target_os = "linux"))]
8526 {
8527 std::process::Command::new("kill")
8528 .args(["-0", &p.to_string()])
8529 .output()
8530 .map(|o| o.status.success())
8531 .unwrap_or(false)
8532 }
8533 };
8534 if alive {
8535 return Ok(());
8536 }
8537 }
8538 }
8539
8540 let bin = std::env::current_exe().with_context(|| "locating self exe")?;
8543 let log_path = session_home.join("state").join("wire").join("daemon.log");
8544 if let Some(parent) = log_path.parent() {
8545 std::fs::create_dir_all(parent).ok();
8546 }
8547 let log_file = std::fs::OpenOptions::new()
8548 .create(true)
8549 .append(true)
8550 .open(&log_path)
8551 .with_context(|| format!("opening daemon log {log_path:?}"))?;
8552 let log_err = log_file.try_clone()?;
8553 std::process::Command::new(&bin)
8554 .env("WIRE_HOME", session_home)
8555 .env_remove("RUST_LOG")
8556 .args(["daemon", "--interval", "5"])
8557 .stdout(log_file)
8558 .stderr(log_err)
8559 .stdin(std::process::Stdio::null())
8560 .spawn()
8561 .with_context(|| "spawning session-local `wire daemon`")?;
8562 Ok(())
8563}
8564
8565fn cmd_session_list(as_json: bool) -> Result<()> {
8566 let items = crate::session::list_sessions()?;
8567 if as_json {
8568 println!("{}", serde_json::to_string(&items)?);
8569 return Ok(());
8570 }
8571 if items.is_empty() {
8572 println!("no sessions on this machine. `wire session new` to create one.");
8573 return Ok(());
8574 }
8575 println!(
8576 "{:<22} {:<24} {:<24} {:<10} CWD",
8577 "CHARACTER", "NAME", "HANDLE", "DAEMON"
8578 );
8579 for s in items {
8580 let plain = s
8584 .character
8585 .as_ref()
8586 .map(|c| c.short())
8587 .unwrap_or_else(|| "?".to_string());
8588 let colored = s
8589 .character
8590 .as_ref()
8591 .map(|c| c.colored())
8592 .unwrap_or_else(|| "?".to_string());
8593 let displayed_width = plain.chars().count() + 1; let pad = 22usize.saturating_sub(displayed_width);
8598 println!(
8599 "{}{} {:<24} {:<24} {:<10} {}",
8600 colored,
8601 " ".repeat(pad),
8602 s.name,
8603 s.handle.as_deref().unwrap_or("?"),
8604 if s.daemon_running { "running" } else { "down" },
8605 s.cwd.as_deref().unwrap_or("(no cwd registered)"),
8606 );
8607 }
8608 Ok(())
8609}
8610
8611fn cmd_session_list_local(as_json: bool) -> Result<()> {
8623 let listing = crate::session::list_local_sessions()?;
8624 if as_json {
8625 println!("{}", serde_json::to_string(&listing)?);
8626 return Ok(());
8627 }
8628
8629 if listing.local.is_empty() && listing.federation_only.is_empty() {
8630 println!(
8631 "no sessions on this machine. `wire session new --with-local` to create one \
8632 with a local-relay endpoint (start the relay first: \
8633 `wire relay-server --bind 127.0.0.1:8771 --local-only`)."
8634 );
8635 return Ok(());
8636 }
8637
8638 if listing.local.is_empty() {
8639 println!(
8640 "no sister sessions reachable via a local relay. \
8641 Re-run `wire session new --with-local` to add a Local endpoint, or \
8642 start a local relay with `wire relay-server --bind 127.0.0.1:8771 --local-only`."
8643 );
8644 } else {
8645 let mut keys: Vec<&String> = listing.local.keys().collect();
8647 keys.sort();
8648 for relay_url in keys {
8649 let group = &listing.local[relay_url];
8650 println!("LOCAL RELAY: {relay_url}");
8651 println!(" {:<24} {:<32} {:<10} CWD", "NAME", "HANDLE", "DAEMON");
8652 for s in group {
8653 println!(
8654 " {:<24} {:<32} {:<10} {}",
8655 s.name,
8656 s.handle.as_deref().unwrap_or("?"),
8657 if s.daemon_running { "running" } else { "down" },
8658 s.cwd.as_deref().unwrap_or("(no cwd registered)"),
8659 );
8660 }
8661 println!();
8662 }
8663 }
8664
8665 if !listing.federation_only.is_empty() {
8666 println!("federation-only (no local endpoint):");
8667 for s in &listing.federation_only {
8668 println!(
8669 " {:<24} {:<32} {}",
8670 s.name,
8671 s.handle.as_deref().unwrap_or("?"),
8672 s.cwd.as_deref().unwrap_or("(no cwd registered)"),
8673 );
8674 }
8675 }
8676 Ok(())
8677}
8678
8679fn cmd_session_pair_all_local(
8698 settle_secs: u64,
8699 federation_relay: &str,
8700 as_json: bool,
8701) -> Result<()> {
8702 use std::collections::BTreeSet;
8703 use std::time::Duration;
8704
8705 let listing = crate::session::list_local_sessions()?;
8706 let mut by_name: std::collections::BTreeMap<String, crate::session::LocalSessionView> =
8710 Default::default();
8711 for group in listing.local.into_values() {
8712 for s in group {
8713 by_name.entry(s.name.clone()).or_insert(s);
8714 }
8715 }
8716 let sessions: Vec<crate::session::LocalSessionView> = by_name.into_values().collect();
8717
8718 if sessions.len() < 2 {
8719 let msg = format!(
8720 "{} sister session(s) with a local endpoint — need at least 2 to pair.",
8721 sessions.len()
8722 );
8723 if as_json {
8724 println!(
8725 "{}",
8726 serde_json::to_string(&json!({
8727 "sessions": sessions.iter().map(|s| &s.name).collect::<Vec<_>>(),
8728 "pairs_attempted": 0,
8729 "pairs_succeeded": 0,
8730 "pairs_skipped_already_paired": 0,
8731 "pairs_failed": 0,
8732 "note": msg,
8733 }))?
8734 );
8735 } else {
8736 println!("{msg}");
8737 if let Some(s) = sessions.first() {
8738 println!(" - {} ({})", s.name, s.cwd.as_deref().unwrap_or("?"));
8739 }
8740 println!("Use `wire session new --with-local` to add more.");
8741 }
8742 return Ok(());
8743 }
8744
8745 let fed_host = host_of_url(federation_relay);
8746 if fed_host.is_empty() {
8747 bail!(
8748 "federation_relay `{federation_relay}` has no parseable host — \
8749 pass a full URL like `https://wireup.net`."
8750 );
8751 }
8752
8753 let mut attempted = 0u32;
8755 let mut succeeded = 0u32;
8756 let mut skipped_already = 0u32;
8757 let mut failed = 0u32;
8758 let mut per_pair: Vec<Value> = Vec::new();
8759
8760 for i in 0..sessions.len() {
8761 for j in (i + 1)..sessions.len() {
8762 let a = &sessions[i];
8763 let b = &sessions[j];
8764 attempted += 1;
8765
8766 let a_handle = a.handle.as_deref().unwrap_or(a.name.as_str());
8772 let b_handle = b.handle.as_deref().unwrap_or(b.name.as_str());
8773 let a_pinned_b = session_has_peer(&a.home_dir, b_handle);
8774 let b_pinned_a = session_has_peer(&b.home_dir, a_handle);
8775 if a_pinned_b && b_pinned_a {
8776 skipped_already += 1;
8777 per_pair.push(json!({
8778 "from": a.name,
8779 "to": b.name,
8780 "status": "already_paired",
8781 }));
8782 continue;
8783 }
8784
8785 let pair_result = drive_bilateral_pair(
8786 &a.home_dir,
8787 &a.name,
8788 &b.home_dir,
8789 &b.name,
8790 &fed_host,
8791 federation_relay,
8792 settle_secs,
8793 );
8794
8795 match pair_result {
8796 Ok(()) => {
8797 succeeded += 1;
8798 per_pair.push(json!({
8799 "from": a.name,
8800 "to": b.name,
8801 "status": "paired",
8802 }));
8803 }
8804 Err(e) => {
8805 failed += 1;
8806 let detail = format!("{e:#}");
8807 per_pair.push(json!({
8808 "from": a.name,
8809 "to": b.name,
8810 "status": "failed",
8811 "error": detail,
8812 }));
8813 }
8814 }
8815
8816 std::thread::sleep(Duration::from_millis(200));
8819 }
8820 }
8821
8822 let _ = BTreeSet::<String>::new(); let summary = json!({
8824 "sessions": sessions.iter().map(|s| s.name.clone()).collect::<Vec<_>>(),
8825 "pairs_attempted": attempted,
8826 "pairs_succeeded": succeeded,
8827 "pairs_skipped_already_paired": skipped_already,
8828 "pairs_failed": failed,
8829 "results": per_pair,
8830 });
8831 if as_json {
8832 println!("{}", serde_json::to_string(&summary)?);
8833 } else {
8834 println!(
8835 "wire session pair-all-local: {} session(s), {} pair(s) attempted",
8836 sessions.len(),
8837 attempted
8838 );
8839 println!(" paired: {succeeded}");
8840 println!(" skipped (already pinned): {skipped_already}");
8841 println!(" failed: {failed}");
8842 for entry in summary["results"].as_array().unwrap_or(&vec![]) {
8843 let from = entry["from"].as_str().unwrap_or("?");
8844 let to = entry["to"].as_str().unwrap_or("?");
8845 let status = entry["status"].as_str().unwrap_or("?");
8846 let err = entry.get("error").and_then(Value::as_str).unwrap_or("");
8847 if err.is_empty() {
8848 println!(" {from:<24} ↔ {to:<24} {status}");
8849 } else {
8850 println!(" {from:<24} ↔ {to:<24} {status} — {err}");
8851 }
8852 }
8853 }
8854 Ok(())
8855}
8856
8857fn session_has_peer(session_home: &std::path::Path, peer_name: &str) -> bool {
8860 val_session_relay_state(session_home)
8861 .and_then(|v| v.get("peers").cloned())
8862 .and_then(|p| p.get(peer_name).cloned())
8863 .is_some()
8864}
8865
8866fn val_session_relay_state(session_home: &std::path::Path) -> Option<Value> {
8871 let path = session_home.join("config").join("wire").join("relay.json");
8872 let bytes = std::fs::read(&path).ok()?;
8873 serde_json::from_slice(&bytes).ok()
8874}
8875
8876fn cmd_session_mesh_status(stale_secs: u64, as_json: bool) -> Result<()> {
8880 use std::collections::BTreeMap;
8881
8882 let listing = crate::session::list_local_sessions()?;
8885 let mut by_name: BTreeMap<String, crate::session::LocalSessionView> = BTreeMap::new();
8886 for group in listing.local.into_values() {
8887 for s in group {
8888 by_name.entry(s.name.clone()).or_insert(s);
8889 }
8890 }
8891 let sessions: Vec<crate::session::LocalSessionView> = by_name.into_values().collect();
8892 let federation_only = listing.federation_only;
8893
8894 if sessions.is_empty() {
8895 let msg = "no sister sessions with a local endpoint on this machine.".to_string();
8896 if as_json {
8897 println!(
8898 "{}",
8899 serde_json::to_string(&json!({
8900 "sessions": [],
8901 "edges": [],
8902 "local_relay": null,
8903 "federation_only": federation_only.iter().map(|f| &f.name).collect::<Vec<_>>(),
8904 "summary": {
8905 "session_count": 0,
8906 "edge_count": 0,
8907 "healthy": 0,
8908 "stale": 0,
8909 "asymmetric": 0,
8910 },
8911 "note": msg,
8912 }))?
8913 );
8914 } else {
8915 println!("{msg}");
8916 println!("Use `wire session new --with-local` to create one.");
8917 }
8918 return Ok(());
8919 }
8920
8921 struct SessionState {
8923 view: crate::session::LocalSessionView,
8924 relay_state: Value,
8925 local_relay_url: Option<String>,
8926 }
8927 let mut sstates: Vec<SessionState> = Vec::with_capacity(sessions.len());
8928 for s in sessions {
8929 let relay_state = val_session_relay_state(&s.home_dir)
8930 .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
8931 let local_relay_url = s.local_endpoints.first().map(|e| e.relay_url.clone());
8932 sstates.push(SessionState {
8933 view: s,
8934 relay_state,
8935 local_relay_url,
8936 });
8937 }
8938
8939 let mut local_relays: BTreeMap<String, bool> = BTreeMap::new();
8942 for s in &sstates {
8943 if let Some(url) = &s.local_relay_url
8944 && !local_relays.contains_key(url)
8945 {
8946 let healthy = probe_relay_healthz(url);
8947 local_relays.insert(url.clone(), healthy);
8948 }
8949 }
8950
8951 let now = std::time::SystemTime::now()
8952 .duration_since(std::time::UNIX_EPOCH)
8953 .map(|d| d.as_secs())
8954 .unwrap_or(0);
8955
8956 let mut edges: Vec<Value> = Vec::new();
8960 let mut healthy_count = 0u32;
8961 let mut stale_count = 0u32;
8962 let mut asymmetric_count = 0u32;
8963
8964 for i in 0..sstates.len() {
8965 for j in (i + 1)..sstates.len() {
8966 let a = &sstates[i];
8967 let b = &sstates[j];
8968 let b_key = b.view.handle.as_deref().unwrap_or(b.view.name.as_str());
8973 let a_key = a.view.handle.as_deref().unwrap_or(a.view.name.as_str());
8974 let a_to_b = probe_directed_edge(&a.relay_state, b_key, now);
8975 let b_to_a = probe_directed_edge(&b.relay_state, a_key, now);
8976
8977 let bilateral = a_to_b.pinned && b_to_a.pinned;
8978 let scope = match (a_to_b.scope.as_deref(), b_to_a.scope.as_deref()) {
8982 (Some("local"), _) | (_, Some("local")) => "local",
8983 (Some("federation"), _) | (_, Some("federation")) => "federation",
8984 _ => "unknown",
8985 };
8986
8987 let mut status = if bilateral { "healthy" } else { "asymmetric" };
8990 if bilateral {
8991 let either_stale = [&a_to_b, &b_to_a].iter().any(|d| match d.silent_secs {
8992 Some(s) => s > stale_secs,
8993 None => d.probed,
8994 });
8995 if either_stale {
8996 status = "stale";
8997 }
8998 }
8999
9000 match status {
9001 "healthy" => healthy_count += 1,
9002 "stale" => stale_count += 1,
9003 "asymmetric" => asymmetric_count += 1,
9004 _ => {}
9005 }
9006
9007 edges.push(json!({
9008 "from": a.view.name,
9009 "to": b.view.name,
9010 "bilateral": bilateral,
9011 "scope": scope,
9012 "status": status,
9013 "directions": {
9014 a.view.name.clone(): direction_summary(&a_to_b),
9015 b.view.name.clone(): direction_summary(&b_to_a),
9016 },
9017 }));
9018 }
9019 }
9020
9021 let summary = json!({
9022 "sessions": sstates.iter().map(|s| json!({
9023 "name": s.view.name,
9024 "handle": s.view.handle,
9025 "cwd": s.view.cwd,
9026 "daemon_running": s.view.daemon_running,
9027 "local_relay": s.local_relay_url,
9028 })).collect::<Vec<_>>(),
9029 "edges": edges,
9030 "local_relays": local_relays.iter().map(|(url, healthy)| json!({
9031 "url": url,
9032 "healthy": healthy,
9033 })).collect::<Vec<_>>(),
9034 "federation_only": federation_only.iter().map(|f| &f.name).collect::<Vec<_>>(),
9035 "summary": {
9036 "session_count": sstates.len(),
9037 "edge_count": edges.len(),
9038 "healthy": healthy_count,
9039 "stale": stale_count,
9040 "asymmetric": asymmetric_count,
9041 "stale_threshold_secs": stale_secs,
9042 },
9043 });
9044
9045 if as_json {
9046 println!("{}", serde_json::to_string(&summary)?);
9047 return Ok(());
9048 }
9049
9050 println!(
9051 "wire mesh: {} session(s), {} edge(s)",
9052 sstates.len(),
9053 edges.len()
9054 );
9055 for (url, healthy) in &local_relays {
9056 let tick = if *healthy { "✓" } else { "✗" };
9057 println!(" local-relay {url} {tick}");
9058 }
9059 if !federation_only.is_empty() {
9060 print!(" federation-only sessions:");
9061 for f in &federation_only {
9062 print!(" {}", f.name);
9063 }
9064 println!();
9065 }
9066
9067 let names: Vec<&str> = sstates.iter().map(|s| s.view.name.as_str()).collect();
9069 let col_w = names.iter().map(|n| n.len()).max().unwrap_or(8).max(7) + 1;
9070 print!("\n{:>col_w$}", "", col_w = col_w);
9071 for n in &names {
9072 print!("{:>col_w$}", n, col_w = col_w);
9073 }
9074 println!();
9075 for (i, row) in names.iter().enumerate() {
9076 print!("{:>col_w$}", row, col_w = col_w);
9077 for (j, col) in names.iter().enumerate() {
9078 let cell = if i == j {
9079 "self".to_string()
9080 } else {
9081 let d = probe_directed_edge(&sstates[i].relay_state, col, now);
9082 match d.scope.as_deref() {
9083 Some("local") => "local".to_string(),
9084 Some("federation") => "fed".to_string(),
9085 _ => "—".to_string(),
9086 }
9087 };
9088 print!("{:>col_w$}", cell, col_w = col_w);
9089 }
9090 println!();
9091 }
9092
9093 println!("\nHealth (stale threshold: {stale_secs}s):");
9094 for e in &edges {
9095 let from = e["from"].as_str().unwrap_or("?");
9096 let to = e["to"].as_str().unwrap_or("?");
9097 let scope = e["scope"].as_str().unwrap_or("?");
9098 let status = e["status"].as_str().unwrap_or("?");
9099 let mark = match status {
9100 "healthy" => "✓",
9101 "stale" => "⚠",
9102 "asymmetric" => "!",
9103 _ => "?",
9104 };
9105 let dirs = e["directions"].as_object().cloned().unwrap_or_default();
9106 let mut details: Vec<String> = Vec::new();
9107 for (who, d) in &dirs {
9108 let silent = d.get("silent_secs").and_then(Value::as_u64);
9109 let pinned = d.get("pinned").and_then(Value::as_bool).unwrap_or(false);
9110 let probed = d.get("probed").and_then(Value::as_bool).unwrap_or(false);
9111 let label = match (pinned, probed, silent) {
9112 (false, _, _) => format!("{who} has not pinned"),
9113 (true, false, _) => format!("{who} pinned but no endpoint to probe"),
9114 (true, true, Some(s)) if s <= stale_secs => format!("{who} fresh ({s}s)"),
9115 (true, true, Some(s)) => format!("{who} silent {s}s"),
9116 (true, true, None) => format!("{who} never pulled"),
9117 };
9118 details.push(label);
9119 }
9120 println!(
9121 " {mark} {from} ↔ {to} scope={scope} {status:>10} [{}]",
9122 details.join(" | ")
9123 );
9124 }
9125 Ok(())
9126}
9127
9128#[derive(Default)]
9129struct DirectedEdge {
9130 pinned: bool,
9131 scope: Option<String>,
9132 last_pull_at_unix: Option<u64>,
9133 silent_secs: Option<u64>,
9134 probed: bool,
9135 event_count: usize,
9136}
9137
9138fn probe_directed_edge(from_state: &Value, to_name: &str, now: u64) -> DirectedEdge {
9144 let pinned = from_state
9145 .get("peers")
9146 .and_then(|p| p.get(to_name))
9147 .is_some();
9148 if !pinned {
9149 return DirectedEdge::default();
9150 }
9151 let endpoints = crate::endpoints::peer_endpoints_in_priority_order(from_state, to_name);
9152 let ep = match endpoints.into_iter().next() {
9153 Some(e) => e,
9154 None => {
9155 return DirectedEdge {
9156 pinned: true,
9157 ..Default::default()
9158 };
9159 }
9160 };
9161 let scope = Some(
9162 match ep.scope {
9163 crate::endpoints::EndpointScope::Local => "local",
9164 crate::endpoints::EndpointScope::Lan => "lan",
9165 crate::endpoints::EndpointScope::Uds => "uds",
9166 crate::endpoints::EndpointScope::Federation => "federation",
9167 }
9168 .to_string(),
9169 );
9170 let client = crate::relay_client::RelayClient::new(&ep.relay_url);
9171 let (count, last) = client
9172 .slot_state(&ep.slot_id, &ep.slot_token)
9173 .unwrap_or((0, None));
9174 let silent = last.map(|t| now.saturating_sub(t));
9175 DirectedEdge {
9176 pinned: true,
9177 scope,
9178 last_pull_at_unix: last,
9179 silent_secs: silent,
9180 probed: true,
9181 event_count: count,
9182 }
9183}
9184
9185fn direction_summary(d: &DirectedEdge) -> Value {
9186 json!({
9187 "pinned": d.pinned,
9188 "scope": d.scope,
9189 "probed": d.probed,
9190 "last_pull_at_unix": d.last_pull_at_unix,
9191 "silent_secs": d.silent_secs,
9192 "event_count": d.event_count,
9193 })
9194}
9195
9196fn probe_relay_healthz(url: &str) -> bool {
9198 let probe_url = format!("{}/healthz", url.trim_end_matches('/'));
9199 let client = match reqwest::blocking::Client::builder()
9200 .timeout(std::time::Duration::from_millis(500))
9201 .build()
9202 {
9203 Ok(c) => c,
9204 Err(_) => return false,
9205 };
9206 match client.get(&probe_url).send() {
9207 Ok(r) => r.status().is_success(),
9208 Err(_) => false,
9209 }
9210}
9211
9212fn drive_bilateral_pair(
9227 a_home: &std::path::Path,
9228 a_name: &str,
9229 b_home: &std::path::Path,
9230 b_name: &str,
9231 _fed_host: &str,
9232 _federation_relay: &str,
9233 settle_secs: u64,
9234) -> Result<()> {
9235 use std::time::Duration;
9236 let bin = std::env::current_exe().context("locating self exe")?;
9237
9238 let run = |home: &std::path::Path, args: &[&str]| -> Result<()> {
9239 let out = std::process::Command::new(&bin)
9240 .env("WIRE_HOME", home)
9241 .env_remove("RUST_LOG")
9242 .args(args)
9243 .output()
9244 .with_context(|| format!("spawning `wire {}`", args.join(" ")))?;
9245 if !out.status.success() {
9246 bail!(
9247 "`wire {}` failed: stderr={}",
9248 args.join(" "),
9249 String::from_utf8_lossy(&out.stderr).trim()
9250 );
9251 }
9252 Ok(())
9253 };
9254
9255 let read_card_handle = |home: &std::path::Path| -> Result<String> {
9260 let card_path = home.join("config").join("wire").join("agent-card.json");
9261 let bytes = std::fs::read(&card_path)
9262 .with_context(|| format!("reading agent-card at {card_path:?}"))?;
9263 let card: Value = serde_json::from_slice(&bytes)?;
9264 card.get("handle")
9265 .and_then(Value::as_str)
9266 .map(str::to_string)
9267 .ok_or_else(|| anyhow!("agent-card at {card_path:?} missing `handle` field"))
9268 };
9269 let a_handle = read_card_handle(a_home)
9270 .with_context(|| format!("session {a_name} (a): read agent-card.handle"))?;
9271 let b_handle = read_card_handle(b_home)
9272 .with_context(|| format!("session {b_name} (b): read agent-card.handle"))?;
9273
9274 run(a_home, &["add", b_name, "--local-sister", "--json"])
9278 .with_context(|| format!("step 1/8: {a_name} `wire add {b_name} --local-sister`"))?;
9279
9280 std::thread::sleep(Duration::from_secs(settle_secs));
9282
9283 run(b_home, &["pull", "--json"]).with_context(|| format!("step 4/8: {b_name} `wire pull`"))?;
9286 run(b_home, &["pair-accept", &a_handle, "--json"]).with_context(|| {
9287 format!("step 5/8: {b_name} `wire pair-accept {a_handle}` (a session={a_name})")
9288 })?;
9289 run(b_home, &["push", "--json"]).with_context(|| format!("step 6/8: {b_name} `wire push`"))?;
9290
9291 std::thread::sleep(Duration::from_secs(settle_secs));
9293
9294 run(a_home, &["pull", "--json"]).with_context(|| format!("step 8/8: {a_name} `wire pull`"))?;
9296 let _ = &b_handle;
9298
9299 Ok(())
9300}
9301
9302fn cmd_session_env(name_arg: Option<&str>, as_json: bool) -> Result<()> {
9303 let name = resolve_session_name(name_arg)?;
9304 let session_home = crate::session::session_dir(&name)?;
9305 if !session_home.exists() {
9306 bail!(
9307 "no session named {name:?} on this machine. `wire session list` to enumerate, \
9308 `wire session new {name}` to create."
9309 );
9310 }
9311 if as_json {
9312 println!(
9313 "{}",
9314 serde_json::to_string(&json!({
9315 "name": name,
9316 "home_dir": session_home.to_string_lossy(),
9317 "export": format!("export WIRE_HOME={}", session_home.to_string_lossy()),
9318 }))?
9319 );
9320 } else {
9321 println!("export WIRE_HOME={}", session_home.to_string_lossy());
9322 }
9323 Ok(())
9324}
9325
9326fn cmd_session_current(as_json: bool) -> Result<()> {
9327 let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
9328 let registry = crate::session::read_registry().unwrap_or_default();
9329 let cwd_key = cwd.to_string_lossy().into_owned();
9330 let name = registry.by_cwd.get(&cwd_key).cloned();
9331 if as_json {
9332 println!(
9333 "{}",
9334 serde_json::to_string(&json!({
9335 "cwd": cwd_key,
9336 "session": name,
9337 }))?
9338 );
9339 } else if let Some(n) = name {
9340 println!("{n}");
9341 } else {
9342 println!("(no session registered for this cwd)");
9343 }
9344 Ok(())
9345}
9346
9347fn cmd_session_destroy(name_arg: &str, force: bool, as_json: bool) -> Result<()> {
9348 let name = crate::session::sanitize_name(name_arg);
9349 let session_home = crate::session::session_dir(&name)?;
9350 if !session_home.exists() {
9351 if as_json {
9352 println!(
9353 "{}",
9354 serde_json::to_string(&json!({
9355 "name": name,
9356 "destroyed": false,
9357 "reason": "no such session",
9358 }))?
9359 );
9360 } else {
9361 println!("no session named {name:?} — nothing to destroy.");
9362 }
9363 return Ok(());
9364 }
9365 if !force {
9366 bail!(
9367 "destroying session {name:?} would delete its keypair + state irrecoverably. \
9368 Pass --force to confirm."
9369 );
9370 }
9371
9372 let pidfile = session_home.join("state").join("wire").join("daemon.pid");
9374 if let Ok(bytes) = std::fs::read(&pidfile) {
9375 let pid: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
9376 v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
9377 } else {
9378 String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
9379 };
9380 if let Some(p) = pid {
9381 let _ = std::process::Command::new("kill")
9382 .args(["-TERM", &p.to_string()])
9383 .output();
9384 }
9385 }
9386
9387 std::fs::remove_dir_all(&session_home)
9388 .with_context(|| format!("removing session dir {session_home:?}"))?;
9389
9390 let mut registry = crate::session::read_registry().unwrap_or_default();
9392 registry.by_cwd.retain(|_, v| v != &name);
9393 crate::session::write_registry(®istry)?;
9394
9395 if as_json {
9396 println!(
9397 "{}",
9398 serde_json::to_string(&json!({
9399 "name": name,
9400 "destroyed": true,
9401 }))?
9402 );
9403 } else {
9404 println!("destroyed session {name:?}.");
9405 }
9406 Ok(())
9407}
9408
9409fn cmd_diag(action: DiagAction) -> Result<()> {
9412 let state = config::state_dir()?;
9413 let knob = state.join("diag.enabled");
9414 let log_path = state.join("diag.jsonl");
9415 match action {
9416 DiagAction::Tail { limit, json } => {
9417 let entries = crate::diag::tail(limit);
9418 if json {
9419 for e in entries {
9420 println!("{}", serde_json::to_string(&e)?);
9421 }
9422 } else if entries.is_empty() {
9423 println!("wire diag: no entries (diag may be disabled — `wire diag enable`)");
9424 } else {
9425 for e in entries {
9426 let ts = e["ts"].as_u64().unwrap_or(0);
9427 let ty = e["type"].as_str().unwrap_or("?");
9428 let pid = e["pid"].as_u64().unwrap_or(0);
9429 let payload = e["payload"].to_string();
9430 println!("[{ts}] pid={pid} {ty} {payload}");
9431 }
9432 }
9433 }
9434 DiagAction::Enable => {
9435 config::ensure_dirs()?;
9436 std::fs::write(&knob, "1")?;
9437 println!("wire diag: enabled at {knob:?}");
9438 }
9439 DiagAction::Disable => {
9440 if knob.exists() {
9441 std::fs::remove_file(&knob)?;
9442 }
9443 println!("wire diag: disabled (env WIRE_DIAG may still flip it on per-process)");
9444 }
9445 DiagAction::Status { json } => {
9446 let enabled = crate::diag::is_enabled();
9447 let size = std::fs::metadata(&log_path).map(|m| m.len()).unwrap_or(0);
9448 if json {
9449 println!(
9450 "{}",
9451 serde_json::to_string(&serde_json::json!({
9452 "enabled": enabled,
9453 "log_path": log_path,
9454 "log_size_bytes": size,
9455 }))?
9456 );
9457 } else {
9458 println!("wire diag status");
9459 println!(" enabled: {enabled}");
9460 println!(" log: {log_path:?}");
9461 println!(" log size: {size} bytes");
9462 }
9463 }
9464 }
9465 Ok(())
9466}
9467
9468fn cmd_service(action: ServiceAction) -> Result<()> {
9471 let kind = |local_relay: bool| {
9472 if local_relay {
9473 crate::service::ServiceKind::LocalRelay
9474 } else {
9475 crate::service::ServiceKind::Daemon
9476 }
9477 };
9478 let (report, as_json) = match action {
9479 ServiceAction::Install { local_relay, json } => {
9480 (crate::service::install_kind(kind(local_relay))?, json)
9481 }
9482 ServiceAction::Uninstall { local_relay, json } => {
9483 (crate::service::uninstall_kind(kind(local_relay))?, json)
9484 }
9485 ServiceAction::Status { local_relay, json } => {
9486 (crate::service::status_kind(kind(local_relay))?, json)
9487 }
9488 };
9489 if as_json {
9490 println!("{}", serde_json::to_string(&report)?);
9491 } else {
9492 println!("wire service {}", report.action);
9493 println!(" platform: {}", report.platform);
9494 println!(" unit: {}", report.unit_path);
9495 println!(" status: {}", report.status);
9496 println!(" detail: {}", report.detail);
9497 }
9498 Ok(())
9499}
9500
9501fn cmd_upgrade(check_only: bool, as_json: bool) -> Result<()> {
9516 let daemon_pids: Vec<u32> = crate::platform::find_processes_by_cmdline("wire daemon");
9525 let relay_pids: Vec<u32> = crate::platform::find_processes_by_cmdline("wire relay-server");
9526 let running_pids: Vec<u32> = daemon_pids
9527 .iter()
9528 .chain(relay_pids.iter())
9529 .copied()
9530 .collect();
9531
9532 let record = crate::ensure_up::read_pid_record("daemon");
9534 let recorded_version: Option<String> = match &record {
9535 crate::ensure_up::PidRecord::Json(d) => Some(d.version.clone()),
9536 crate::ensure_up::PidRecord::LegacyInt(_) => Some("<pre-0.5.11>".to_string()),
9537 _ => None,
9538 };
9539 let cli_version = env!("CARGO_PKG_VERSION").to_string();
9540
9541 let sessions_to_respawn_after_kill: Vec<std::path::PathBuf> = crate::session::list_sessions()
9548 .unwrap_or_default()
9549 .into_iter()
9550 .filter(|s| s.daemon_running)
9551 .map(|s| s.home_dir)
9552 .collect();
9553
9554 if check_only {
9555 let sessions_with_daemons: Vec<String> = crate::session::list_sessions()
9557 .unwrap_or_default()
9558 .iter()
9559 .filter(|s| s.daemon_running)
9560 .map(|s| s.name.clone())
9561 .collect();
9562 let mut path_dupes: Vec<String> = Vec::new();
9563 if let Ok(path) = std::env::var("PATH") {
9564 let mut seen: std::collections::HashSet<std::path::PathBuf> =
9565 std::collections::HashSet::new();
9566 for dir in path.split(':') {
9567 let candidate = std::path::PathBuf::from(dir).join("wire");
9568 if candidate.exists() {
9569 let canon = candidate.canonicalize().unwrap_or(candidate);
9570 if seen.insert(canon.clone()) {
9571 path_dupes.push(canon.to_string_lossy().into_owned());
9572 }
9573 }
9574 }
9575 }
9576 let installed_service_kinds: Vec<&'static str> = [
9579 (crate::service::ServiceKind::Daemon, "daemon"),
9580 (crate::service::ServiceKind::LocalRelay, "local-relay"),
9581 ]
9582 .into_iter()
9583 .filter_map(|(k, label)| {
9584 crate::service::status_kind(k)
9585 .ok()
9586 .filter(|r| r.status != "absent")
9587 .map(|_| label)
9588 })
9589 .collect();
9590 let report = json!({
9591 "running_pids": running_pids,
9592 "running_daemons": daemon_pids,
9593 "running_relay_servers": relay_pids,
9594 "pidfile_version": recorded_version,
9595 "cli_version": cli_version,
9596 "would_kill": running_pids,
9597 "would_refresh_services": installed_service_kinds,
9598 "session_daemons_running": sessions_with_daemons,
9599 "path_binaries": path_dupes,
9600 "path_duplicate_warning": path_dupes.len() > 1,
9601 });
9602 if as_json {
9603 println!("{}", serde_json::to_string(&report)?);
9604 } else {
9605 println!("wire upgrade --check");
9606 println!(" cli version: {cli_version}");
9607 println!(
9608 " pidfile version: {}",
9609 recorded_version.as_deref().unwrap_or("(missing)")
9610 );
9611 if running_pids.is_empty() {
9612 println!(" running daemons: none");
9613 println!(" running relays: none");
9614 } else {
9615 if daemon_pids.is_empty() {
9616 println!(" running daemons: none");
9617 } else {
9618 let p: Vec<String> = daemon_pids.iter().map(|p| p.to_string()).collect();
9619 println!(" running daemons: pids {}", p.join(", "));
9620 }
9621 if relay_pids.is_empty() {
9622 println!(" running relays: none");
9623 } else {
9624 let p: Vec<String> = relay_pids.iter().map(|p| p.to_string()).collect();
9625 println!(" running relays: pids {}", p.join(", "));
9626 }
9627 println!(" would kill all + spawn fresh");
9628 }
9629 if !installed_service_kinds.is_empty() {
9630 println!(
9631 " would refresh: {} installed service unit(s) → new binary path",
9632 installed_service_kinds.join(", ")
9633 );
9634 }
9635 if !sessions_with_daemons.is_empty() {
9636 println!(
9637 " session daemons: {} (would respawn under new binary)",
9638 sessions_with_daemons.join(", ")
9639 );
9640 }
9641 if path_dupes.len() > 1 {
9642 println!(
9643 " PATH warning: {} distinct `wire` binaries on PATH:",
9644 path_dupes.len()
9645 );
9646 for b in &path_dupes {
9647 println!(" {b}");
9648 }
9649 println!(" operators should remove the stale ones");
9650 }
9651 }
9652 return Ok(());
9653 }
9654
9655 let mut killed: Vec<u32> = Vec::new();
9662 for pid in &running_pids {
9663 if crate::platform::kill_process(*pid, false) {
9664 killed.push(*pid);
9665 }
9666 }
9667 if !killed.is_empty() {
9669 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
9670 loop {
9671 let still_alive: Vec<u32> = killed
9672 .iter()
9673 .copied()
9674 .filter(|p| process_alive_pid(*p))
9675 .collect();
9676 if still_alive.is_empty() {
9677 break;
9678 }
9679 if std::time::Instant::now() >= deadline {
9680 for pid in still_alive {
9682 let _ = crate::platform::kill_process(pid, true);
9683 }
9684 break;
9685 }
9686 std::thread::sleep(std::time::Duration::from_millis(50));
9687 }
9688 }
9689
9690 let pidfile = config::state_dir()?.join("daemon.pid");
9693 if pidfile.exists() {
9694 let _ = std::fs::remove_file(&pidfile);
9695 }
9696
9697 if let Ok(sessions) = crate::session::list_sessions() {
9704 for s in &sessions {
9705 let session_pidfile = s.home_dir.join("state").join("wire").join("daemon.pid");
9706 if session_pidfile.exists() {
9707 let _ = std::fs::remove_file(&session_pidfile);
9708 }
9709 }
9710 }
9711 let session_daemons_to_respawn = sessions_to_respawn_after_kill;
9712
9713 let mut path_dupes: Vec<String> = Vec::new();
9718 if let Ok(path) = std::env::var("PATH") {
9719 let mut seen: std::collections::HashSet<std::path::PathBuf> =
9720 std::collections::HashSet::new();
9721 for dir in path.split(':') {
9722 let candidate = std::path::PathBuf::from(dir).join("wire");
9723 if candidate.exists() {
9724 let canon = candidate.canonicalize().unwrap_or(candidate);
9725 if seen.insert(canon.clone()) {
9726 path_dupes.push(canon.to_string_lossy().into_owned());
9727 }
9728 }
9729 }
9730 }
9731 let path_warning = if path_dupes.len() > 1 {
9732 Some(format!(
9733 "WARN: {} distinct `wire` binaries on PATH — old versions can shadow the fresh install:\n {}",
9734 path_dupes.len(),
9735 path_dupes.join("\n ")
9736 ))
9737 } else {
9738 None
9739 };
9740
9741 let mut service_refreshes: Vec<Value> = Vec::new();
9755 for kind in [
9756 crate::service::ServiceKind::Daemon,
9757 crate::service::ServiceKind::LocalRelay,
9758 ] {
9759 let already_installed = crate::service::status_kind(kind)
9760 .map(|r| r.status != "absent")
9761 .unwrap_or(false);
9762 if !already_installed {
9763 continue;
9764 }
9765 match crate::service::install_kind(kind) {
9766 Ok(rep) => service_refreshes.push(json!({
9767 "kind": rep.kind,
9768 "platform": rep.platform,
9769 "status": rep.status,
9770 "unit_path": rep.unit_path,
9771 "action": "refreshed",
9772 })),
9773 Err(e) => service_refreshes.push(json!({
9774 "kind": format!("{kind:?}"),
9775 "action": "refresh_failed",
9776 "error": format!("{e:#}"),
9777 })),
9778 }
9779 }
9780
9781 let spawned = crate::ensure_up::ensure_daemon_running()?;
9787
9788 let mut session_respawns: Vec<Value> = Vec::new();
9794 for home in &session_daemons_to_respawn {
9795 match ensure_session_daemon(home) {
9796 Ok(()) => session_respawns.push(json!({
9797 "session_home": home.to_string_lossy(),
9798 "status": "respawned",
9799 })),
9800 Err(e) => session_respawns.push(json!({
9801 "session_home": home.to_string_lossy(),
9802 "status": "failed",
9803 "error": format!("{e:#}"),
9804 })),
9805 }
9806 }
9807
9808 let new_record = crate::ensure_up::read_pid_record("daemon");
9809 let new_pid = new_record.pid();
9810 let new_version: Option<String> = if let crate::ensure_up::PidRecord::Json(d) = &new_record {
9811 Some(d.version.clone())
9812 } else {
9813 None
9814 };
9815
9816 if as_json {
9817 println!(
9818 "{}",
9819 serde_json::to_string(&json!({
9820 "killed": killed,
9821 "killed_daemons": daemon_pids,
9822 "killed_relay_servers": relay_pids,
9823 "service_refreshes": service_refreshes,
9824 "spawned_fresh_daemon": spawned,
9825 "new_pid": new_pid,
9826 "new_version": new_version,
9827 "cli_version": cli_version,
9828 "session_respawns": session_respawns,
9829 "path_binaries": path_dupes,
9830 "path_warning": path_warning,
9831 }))?
9832 );
9833 } else {
9834 if killed.is_empty() {
9835 println!("wire upgrade: no stale wire processes running");
9836 } else {
9837 println!(
9838 "wire upgrade: killed {} process(es) — {} daemon(s) + {} relay-server(s) (pids {})",
9839 killed.len(),
9840 daemon_pids.len(),
9841 relay_pids.len(),
9842 killed
9843 .iter()
9844 .map(|p| p.to_string())
9845 .collect::<Vec<_>>()
9846 .join(", ")
9847 );
9848 }
9849 if !service_refreshes.is_empty() {
9850 println!(
9851 "wire upgrade: refreshed {} installed service unit(s) to point at the new binary:",
9852 service_refreshes.len()
9853 );
9854 for r in &service_refreshes {
9855 let kind = r.get("kind").and_then(Value::as_str).unwrap_or("?");
9856 let action = r.get("action").and_then(Value::as_str).unwrap_or("?");
9857 let status = r.get("status").and_then(Value::as_str).unwrap_or("");
9858 let platform = r.get("platform").and_then(Value::as_str).unwrap_or("");
9859 if action == "refreshed" {
9860 println!(" - {kind}: {action} ({status}, {platform})");
9861 } else {
9862 let err = r.get("error").and_then(Value::as_str).unwrap_or("");
9863 println!(" - {kind}: {action} ({err})");
9864 }
9865 }
9866 }
9867 if spawned {
9868 println!(
9869 "wire upgrade: spawned fresh daemon (pid {} v{})",
9870 new_pid
9871 .map(|p| p.to_string())
9872 .unwrap_or_else(|| "?".to_string()),
9873 new_version.as_deref().unwrap_or(&cli_version),
9874 );
9875 } else {
9876 println!("wire upgrade: daemon was already running on current binary");
9877 }
9878 if !session_respawns.is_empty() {
9879 println!(
9880 "wire upgrade: refreshed {} session daemon(s):",
9881 session_respawns.len()
9882 );
9883 for r in &session_respawns {
9884 let h = r["session_home"].as_str().unwrap_or("?");
9885 let s = r["status"].as_str().unwrap_or("?");
9886 let label = std::path::Path::new(h)
9887 .file_name()
9888 .map(|f| f.to_string_lossy().into_owned())
9889 .unwrap_or_else(|| h.to_string());
9890 println!(" {label:<24} {s}");
9891 }
9892 }
9893 if let Some(msg) = &path_warning {
9894 eprintln!("wire upgrade: {msg}");
9895 }
9896 }
9897 Ok(())
9898}
9899
9900fn json_default(explicit: bool) -> bool {
9910 if explicit {
9911 return true;
9912 }
9913 if std::env::var("WIRE_NO_AUTO_JSON").is_ok() {
9914 return false;
9915 }
9916 use std::io::IsTerminal;
9917 !std::io::stdout().is_terminal()
9918}
9919
9920fn process_alive_pid(pid: u32) -> bool {
9921 crate::platform::process_alive(pid)
9926}
9927
9928fn levenshtein_ci(a: &str, b: &str) -> usize {
9934 let a: Vec<char> = a.to_ascii_lowercase().chars().collect();
9935 let b: Vec<char> = b.to_ascii_lowercase().chars().collect();
9936 let (a, b) = if a.len() < b.len() { (a, b) } else { (b, a) };
9937 let (m, n) = (a.len(), b.len());
9938 if m == 0 {
9939 return n;
9940 }
9941 let mut prev: Vec<usize> = (0..=m).collect();
9942 let mut curr = vec![0usize; m + 1];
9943 for j in 1..=n {
9944 curr[0] = j;
9945 for i in 1..=m {
9946 let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
9947 curr[i] = std::cmp::min(
9948 std::cmp::min(curr[i - 1] + 1, prev[i] + 1),
9949 prev[i - 1] + cost,
9950 );
9951 }
9952 std::mem::swap(&mut prev, &mut curr);
9953 }
9954 prev[m]
9955}
9956
9957pub fn closest_candidates(
9961 needle: &str,
9962 pool: &[String],
9963 max_distance: usize,
9964 max_results: usize,
9965) -> Vec<String> {
9966 let mut scored: Vec<(usize, &String)> = pool
9967 .iter()
9968 .map(|c| (levenshtein_ci(needle, c), c))
9969 .filter(|(d, _)| *d <= max_distance)
9970 .collect();
9971 scored.sort_by_key(|(d, _)| *d);
9972 scored
9973 .into_iter()
9974 .take(max_results)
9975 .map(|(_, c)| c.clone())
9976 .collect()
9977}
9978
9979fn known_local_names() -> Vec<String> {
9984 let mut names: Vec<String> = Vec::new();
9985 if let Ok(trust) = config::read_trust() {
9986 if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
9992 for (handle, agent) in agents {
9993 names.push(handle.clone());
9994 if let Some(did) = agent.get("did").and_then(Value::as_str) {
9995 let ch = crate::character::Character::from_did(did);
9996 names.push(ch.nickname);
9997 }
9998 }
9999 }
10000 }
10001 if let Ok(sessions) = crate::session::list_sessions() {
10002 for s in sessions {
10003 names.push(s.name.clone());
10004 if let Some(h) = &s.handle {
10005 names.push(h.clone());
10006 }
10007 if let Some(ch) = &s.character {
10008 names.push(ch.nickname.clone());
10009 }
10010 }
10011 }
10012 names.sort();
10013 names.dedup();
10014 names
10015}
10016
10017fn deprecation_warn(verb: &str, replacement: &str, json_mode: bool) {
10025 if json_mode {
10026 return;
10027 }
10028 let key = format!("WIRE_DEPRECATION_NAGGED_{}", verb.replace('-', "_"));
10036 if std::env::var(&key).is_ok() {
10037 return;
10038 }
10039 unsafe {
10043 std::env::set_var(&key, "1");
10044 }
10045 eprintln!(
10046 "wire {verb}: DEPRECATED in v0.9 — use `wire {replacement}`. \
10047 Will be removed in v1.0 (target 2026-Q3). \
10048 Suppress: set WIRE_DEPRECATION_NAGGED_{}=1.",
10049 verb.replace('-', "_")
10050 );
10051}
10052
10053#[derive(Clone, Debug, serde::Serialize)]
10057pub struct DoctorCheck {
10058 pub id: String,
10061 pub status: String,
10063 pub detail: String,
10065 #[serde(skip_serializing_if = "Option::is_none")]
10067 pub fix: Option<String>,
10068}
10069
10070impl DoctorCheck {
10071 fn pass(id: &str, detail: impl Into<String>) -> Self {
10072 Self {
10073 id: id.into(),
10074 status: "PASS".into(),
10075 detail: detail.into(),
10076 fix: None,
10077 }
10078 }
10079 fn warn(id: &str, detail: impl Into<String>, fix: impl Into<String>) -> Self {
10080 Self {
10081 id: id.into(),
10082 status: "WARN".into(),
10083 detail: detail.into(),
10084 fix: Some(fix.into()),
10085 }
10086 }
10087 fn fail(id: &str, detail: impl Into<String>, fix: impl Into<String>) -> Self {
10088 Self {
10089 id: id.into(),
10090 status: "FAIL".into(),
10091 detail: detail.into(),
10092 fix: Some(fix.into()),
10093 }
10094 }
10095}
10096
10097fn cmd_doctor(as_json: bool, recent_rejections: usize) -> Result<()> {
10102 let checks: Vec<DoctorCheck> = vec![
10103 check_daemon_health(),
10104 check_daemon_pid_consistency(),
10105 check_relay_reachable(),
10106 check_pair_rejections(recent_rejections),
10107 check_cursor_progress(),
10108 ];
10109
10110 let fails = checks.iter().filter(|c| c.status == "FAIL").count();
10111 let warns = checks.iter().filter(|c| c.status == "WARN").count();
10112
10113 if as_json {
10114 println!(
10115 "{}",
10116 serde_json::to_string(&json!({
10117 "checks": checks,
10118 "fail_count": fails,
10119 "warn_count": warns,
10120 "ok": fails == 0,
10121 }))?
10122 );
10123 } else {
10124 println!("wire doctor — {} checks", checks.len());
10125 for c in &checks {
10126 let bullet = match c.status.as_str() {
10127 "PASS" => "✓",
10128 "WARN" => "!",
10129 "FAIL" => "✗",
10130 _ => "?",
10131 };
10132 println!(" {bullet} [{}] {}: {}", c.status, c.id, c.detail);
10133 if let Some(fix) = &c.fix {
10134 println!(" fix: {fix}");
10135 }
10136 }
10137 println!();
10138 if fails == 0 && warns == 0 {
10139 println!("ALL GREEN");
10140 } else {
10141 println!("{fails} FAIL, {warns} WARN");
10142 }
10143 }
10144
10145 if fails > 0 {
10146 std::process::exit(1);
10147 }
10148 Ok(())
10149}
10150
10151fn check_daemon_health() -> DoctorCheck {
10158 let snap = crate::ensure_up::daemon_liveness();
10164 let pgrep_pids = &snap.pgrep_pids;
10165 let pidfile_pid = snap.pidfile_pid;
10166 let pidfile_alive = snap.pidfile_alive;
10167 let orphan_pids = &snap.orphan_pids;
10168
10169 let fmt_pids = |xs: &[u32]| -> String {
10170 xs.iter()
10171 .map(|p| p.to_string())
10172 .collect::<Vec<_>>()
10173 .join(", ")
10174 };
10175
10176 match (pgrep_pids.len(), pidfile_alive, orphan_pids.is_empty()) {
10177 (0, _, _) => DoctorCheck::fail(
10178 "daemon",
10179 "no `wire daemon` process running — nothing pulling inbox or pushing outbox",
10180 "`wire daemon &` to start, or re-run `wire up <handle>@<relay>` to bootstrap",
10181 ),
10182 (1, true, true) => DoctorCheck::pass(
10184 "daemon",
10185 format!(
10186 "one daemon running (pid {}, matches pidfile)",
10187 pgrep_pids[0]
10188 ),
10189 ),
10190 (n, true, false) => DoctorCheck::fail(
10192 "daemon",
10193 format!(
10194 "{n} `wire daemon` processes running (pids: {}); pidfile claims pid {} but pgrep also sees orphan(s): {}. \
10195 The orphans race the relay cursor — they advance past events your current binary can't process. \
10196 (Issue #2 exact class.)",
10197 fmt_pids(pgrep_pids),
10198 pidfile_pid.unwrap(),
10199 fmt_pids(orphan_pids),
10200 ),
10201 "`wire upgrade` kills all orphans and spawns a fresh daemon with a clean pidfile",
10202 ),
10203 (n, false, _) => DoctorCheck::fail(
10205 "daemon",
10206 format!(
10207 "{n} `wire daemon` process(es) running (pids: {}) but pidfile {} — \
10208 every running daemon is an orphan, advancing the cursor without coordinating with the current CLI. \
10209 (Issue #2 exact class: doctor previously PASSed this state while `wire status` said DOWN.)",
10210 fmt_pids(pgrep_pids),
10211 match pidfile_pid {
10212 Some(p) => format!("claims pid {p} which is dead"),
10213 None => "is missing".to_string(),
10214 },
10215 ),
10216 "`wire upgrade` to kill the orphan(s) and spawn a fresh daemon",
10217 ),
10218 (n, true, true) => DoctorCheck::warn(
10220 "daemon",
10221 format!(
10222 "{n} `wire daemon` processes running (pids: {}). Multiple daemons race the relay cursor.",
10223 fmt_pids(pgrep_pids)
10224 ),
10225 "kill all-but-one: `pkill -f \"wire daemon\"; wire daemon &`",
10226 ),
10227 }
10228}
10229
10230fn check_daemon_pid_consistency() -> DoctorCheck {
10242 let snap = crate::ensure_up::daemon_liveness();
10243 match &snap.record {
10244 crate::ensure_up::PidRecord::Missing => DoctorCheck::pass(
10245 "daemon_pid_consistency",
10246 "no daemon.pid yet — fresh box or daemon never started",
10247 ),
10248 crate::ensure_up::PidRecord::Corrupt(reason) => DoctorCheck::warn(
10249 "daemon_pid_consistency",
10250 format!("daemon.pid is corrupt: {reason}"),
10251 "delete state/wire/daemon.pid; next `wire daemon &` will rewrite",
10252 ),
10253 crate::ensure_up::PidRecord::LegacyInt(pid) => {
10254 let pid = *pid;
10257 if !crate::ensure_up::pid_is_alive(pid) {
10258 return DoctorCheck::warn(
10259 "daemon_pid_consistency",
10260 format!(
10261 "daemon.pid (legacy-int) points at pid {pid} which is not running. \
10262 Stale pidfile from a crashed pre-0.5.11 daemon. \
10263 (Issue #2: this surface used to PASS while `wire status` said DOWN.)"
10264 ),
10265 "`wire upgrade` (kills any orphan + spawns a fresh daemon with JSON pidfile)",
10266 );
10267 }
10268 DoctorCheck::warn(
10269 "daemon_pid_consistency",
10270 format!(
10271 "daemon.pid is legacy-int form (pid={pid}, no version/bin_path metadata). \
10272 Daemon was started by a pre-0.5.11 binary."
10273 ),
10274 "run `wire upgrade` to kill the old daemon and start a fresh one with the JSON pidfile",
10275 )
10276 }
10277 crate::ensure_up::PidRecord::Json(d) => {
10278 if !snap.pidfile_alive {
10282 return DoctorCheck::warn(
10283 "daemon_pid_consistency",
10284 format!(
10285 "daemon.pid records pid {pid} (v{version}) but that process is not running — \
10286 pidfile is stale. `wire status` will report DOWN, but pre-v0.5.19 doctor \
10287 silently PASSed this state and ignored any live orphan daemons (#2 root cause).",
10288 pid = d.pid,
10289 version = d.version,
10290 ),
10291 "`wire upgrade` to clean up the stale pidfile + spawn a fresh daemon \
10292 (kills any orphan daemon advancing the cursor without coordination)",
10293 );
10294 }
10295 let mut issues: Vec<String> = Vec::new();
10296 if d.schema != crate::ensure_up::DAEMON_PID_SCHEMA {
10297 issues.push(format!(
10298 "schema={} (expected {})",
10299 d.schema,
10300 crate::ensure_up::DAEMON_PID_SCHEMA
10301 ));
10302 }
10303 let cli_version = env!("CARGO_PKG_VERSION");
10304 if d.version != cli_version {
10305 issues.push(format!("version daemon={} cli={cli_version}", d.version));
10306 }
10307 if !std::path::Path::new(&d.bin_path).exists() {
10308 issues.push(format!("bin_path {} missing on disk", d.bin_path));
10309 }
10310 if let Ok(card) = config::read_agent_card()
10312 && let Some(current_did) = card.get("did").and_then(Value::as_str)
10313 && let Some(recorded_did) = &d.did
10314 && recorded_did != current_did
10315 {
10316 issues.push(format!(
10317 "did daemon={recorded_did} config={current_did} — identity drift"
10318 ));
10319 }
10320 if let Ok(state) = config::read_relay_state()
10321 && let Some(current_relay) = state
10322 .get("self")
10323 .and_then(|s| s.get("relay_url"))
10324 .and_then(Value::as_str)
10325 && let Some(recorded_relay) = &d.relay_url
10326 && recorded_relay != current_relay
10327 {
10328 issues.push(format!(
10329 "relay_url daemon={recorded_relay} config={current_relay} — relay-migration drift"
10330 ));
10331 }
10332 if issues.is_empty() {
10333 DoctorCheck::pass(
10334 "daemon_pid_consistency",
10335 format!(
10336 "daemon v{} bound to {} as {}",
10337 d.version,
10338 d.relay_url.as_deref().unwrap_or("?"),
10339 d.did.as_deref().unwrap_or("?")
10340 ),
10341 )
10342 } else {
10343 DoctorCheck::warn(
10344 "daemon_pid_consistency",
10345 format!("daemon pidfile drift: {}", issues.join("; ")),
10346 "`wire upgrade` to atomically restart daemon with current config".to_string(),
10347 )
10348 }
10349 }
10350 }
10351}
10352
10353fn check_relay_reachable() -> DoctorCheck {
10355 let state = match config::read_relay_state() {
10356 Ok(s) => s,
10357 Err(e) => {
10358 return DoctorCheck::fail(
10359 "relay",
10360 format!("could not read relay state: {e}"),
10361 "run `wire up <handle>@<relay>` to bootstrap",
10362 );
10363 }
10364 };
10365 let url = state
10366 .get("self")
10367 .and_then(|s| s.get("relay_url"))
10368 .and_then(Value::as_str)
10369 .unwrap_or("");
10370 if url.is_empty() {
10371 return DoctorCheck::warn(
10372 "relay",
10373 "no relay bound — wire send/pull will not work",
10374 "run `wire bind-relay <url>` or `wire up <handle>@<relay>`",
10375 );
10376 }
10377 let client = crate::relay_client::RelayClient::new(url);
10378 match client.check_healthz() {
10379 Ok(()) => DoctorCheck::pass("relay", format!("{url} healthz=200")),
10380 Err(e) => DoctorCheck::fail(
10381 "relay",
10382 format!("{url} unreachable: {e}"),
10383 format!("network reachable to {url}? relay running? check `curl {url}/healthz`"),
10384 ),
10385 }
10386}
10387
10388fn check_pair_rejections(recent_n: usize) -> DoctorCheck {
10392 let path = match config::state_dir() {
10393 Ok(d) => d.join("pair-rejected.jsonl"),
10394 Err(e) => {
10395 return DoctorCheck::warn(
10396 "pair_rejections",
10397 format!("could not resolve state dir: {e}"),
10398 "set WIRE_HOME or fix XDG_STATE_HOME",
10399 );
10400 }
10401 };
10402 if !path.exists() {
10403 return DoctorCheck::pass(
10404 "pair_rejections",
10405 "no pair-rejected.jsonl — no recorded pair failures",
10406 );
10407 }
10408 let body = match std::fs::read_to_string(&path) {
10409 Ok(b) => b,
10410 Err(e) => {
10411 return DoctorCheck::warn(
10412 "pair_rejections",
10413 format!("could not read {path:?}: {e}"),
10414 "check file permissions",
10415 );
10416 }
10417 };
10418 let lines: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
10419 if lines.is_empty() {
10420 return DoctorCheck::pass("pair_rejections", "pair-rejected.jsonl present but empty");
10421 }
10422 let total = lines.len();
10423 let recent: Vec<&str> = lines.iter().rev().take(recent_n).rev().copied().collect();
10424 let mut summary: Vec<String> = Vec::new();
10425 for line in &recent {
10426 if let Ok(rec) = serde_json::from_str::<Value>(line) {
10427 let peer = rec.get("peer").and_then(Value::as_str).unwrap_or("?");
10428 let code = rec.get("code").and_then(Value::as_str).unwrap_or("?");
10429 summary.push(format!("{peer}/{code}"));
10430 }
10431 }
10432 DoctorCheck::warn(
10433 "pair_rejections",
10434 format!(
10435 "{total} pair failures recorded. recent: [{}]",
10436 summary.join(", ")
10437 ),
10438 format!(
10439 "inspect {path:?} for full details. Each entry is a pair-flow error that previously silently dropped — re-run `wire pair <handle>@<relay>` to retry."
10440 ),
10441 )
10442}
10443
10444fn check_cursor_progress() -> DoctorCheck {
10449 let state = match config::read_relay_state() {
10450 Ok(s) => s,
10451 Err(e) => {
10452 return DoctorCheck::warn(
10453 "cursor",
10454 format!("could not read relay state: {e}"),
10455 "check ~/Library/Application Support/wire/relay.json",
10456 );
10457 }
10458 };
10459 let cursor = state
10460 .get("self")
10461 .and_then(|s| s.get("last_pulled_event_id"))
10462 .and_then(Value::as_str)
10463 .map(|s| s.chars().take(16).collect::<String>())
10464 .unwrap_or_else(|| "<none>".to_string());
10465 DoctorCheck::pass(
10466 "cursor",
10467 format!(
10468 "current cursor: {cursor}. P0.1 cursor blocking is active — see `wire pull --json` for cursor_blocked / rejected[].blocks_cursor entries."
10469 ),
10470 )
10471}
10472
10473#[cfg(test)]
10474mod doctor_tests {
10475 use super::*;
10476
10477 #[test]
10478 fn doctor_check_constructors_set_status_correctly() {
10479 let p = DoctorCheck::pass("x", "ok");
10484 assert_eq!(p.status, "PASS");
10485 assert_eq!(p.fix, None);
10486
10487 let w = DoctorCheck::warn("x", "watch out", "do this");
10488 assert_eq!(w.status, "WARN");
10489 assert_eq!(w.fix, Some("do this".to_string()));
10490
10491 let f = DoctorCheck::fail("x", "broken", "fix it");
10492 assert_eq!(f.status, "FAIL");
10493 assert_eq!(f.fix, Some("fix it".to_string()));
10494 }
10495
10496 #[test]
10497 fn check_pair_rejections_no_file_is_pass() {
10498 config::test_support::with_temp_home(|| {
10501 config::ensure_dirs().unwrap();
10502 let c = check_pair_rejections(5);
10503 assert_eq!(c.status, "PASS", "no file should be PASS, got {c:?}");
10504 });
10505 }
10506
10507 #[test]
10508 fn check_pair_rejections_with_entries_warns() {
10509 config::test_support::with_temp_home(|| {
10513 config::ensure_dirs().unwrap();
10514 crate::pair_invite::record_pair_rejection(
10515 "willard",
10516 "pair_drop_ack_send_failed",
10517 "POST 502",
10518 );
10519 let c = check_pair_rejections(5);
10520 assert_eq!(c.status, "WARN");
10521 assert!(c.detail.contains("1 pair failures"));
10522 assert!(c.detail.contains("willard/pair_drop_ack_send_failed"));
10523 });
10524 }
10525}
10526
10527fn cmd_up(
10539 handle_arg: &str,
10540 name: Option<&str>,
10541 with_local: Option<&str>,
10542 no_local: bool,
10543 as_json: bool,
10544) -> Result<()> {
10545 let (nick, relay_url) = match handle_arg.split_once('@') {
10546 Some((n, host)) => {
10547 let url = if host.starts_with("http://") || host.starts_with("https://") {
10548 host.to_string()
10549 } else {
10550 format!("https://{host}")
10551 };
10552 (n.to_string(), url)
10553 }
10554 None => (
10555 handle_arg.to_string(),
10556 crate::pair_invite::DEFAULT_RELAY.to_string(),
10557 ),
10558 };
10559
10560 let mut report: Vec<(String, String)> = Vec::new();
10561 let mut step = |stage: &str, detail: String| {
10562 report.push((stage.to_string(), detail.clone()));
10563 if !as_json {
10564 eprintln!("wire up: {stage} — {detail}");
10565 }
10566 };
10567
10568 if config::is_initialized()? {
10575 step("init", "already initialized".to_string());
10576 } else {
10577 cmd_init(
10578 &nick,
10579 name,
10580 Some(&relay_url),
10581 false,
10582 false,
10583 )?;
10584 step("init", format!("created identity bound to {relay_url}"));
10585 }
10586
10587 let canonical = {
10589 let card = config::read_agent_card()?;
10590 let did = card.get("did").and_then(Value::as_str).unwrap_or("");
10591 crate::agent_card::display_handle_from_did(did).to_string()
10592 };
10593 if canonical != nick {
10594 step(
10595 "identity",
10596 format!("persona is `{canonical}` (typed `{nick}` ignored — one-name rule)"),
10597 );
10598 }
10599
10600 let relay_state = config::read_relay_state()?;
10604 let bound_relay = relay_state
10605 .get("self")
10606 .and_then(|s| s.get("relay_url"))
10607 .and_then(Value::as_str)
10608 .unwrap_or("")
10609 .to_string();
10610 if bound_relay.is_empty() {
10611 cmd_bind_relay(
10615 &relay_url,
10616 None, false,
10618 false,
10619 false,
10620 )?;
10621 step("bind-relay", format!("bound to {relay_url}"));
10622 } else if bound_relay != relay_url {
10623 step(
10624 "bind-relay",
10625 format!(
10626 "WARNING: identity bound to {bound_relay} but you specified {relay_url}. \
10627 Keeping existing binding. Run `wire bind-relay {relay_url}` to switch."
10628 ),
10629 );
10630 } else {
10631 step("bind-relay", format!("already bound to {bound_relay}"));
10632 }
10633
10634 match cmd_claim(
10637 &canonical,
10638 Some(&relay_url),
10639 None,
10640 false,
10641 false,
10642 ) {
10643 Ok(()) => step(
10644 "claim",
10645 format!("{canonical}@{} claimed", strip_proto(&relay_url)),
10646 ),
10647 Err(e) => step(
10648 "claim",
10649 format!("WARNING: claim failed: {e}. You can retry `wire claim {canonical}`."),
10650 ),
10651 }
10652
10653 if no_local {
10658 step("local-slot", "skipped (--no-local)".to_string());
10659 } else {
10660 let local_url = with_local
10661 .unwrap_or("http://127.0.0.1:8771")
10662 .trim_end_matches('/');
10663 let already_local = crate::endpoints::self_endpoints(
10664 &config::read_relay_state().unwrap_or_else(|_| json!({})),
10665 )
10666 .iter()
10667 .any(|e| e.relay_url == local_url);
10668 if relay_url.trim_end_matches('/') == local_url || already_local {
10669 step("local-slot", "already covered".to_string());
10670 } else if crate::relay_client::RelayClient::new(local_url)
10671 .check_healthz()
10672 .is_ok()
10673 {
10674 match cmd_bind_relay(
10675 local_url,
10676 Some("local"),
10677 false,
10678 false,
10679 false,
10680 ) {
10681 Ok(()) => step(
10682 "local-slot",
10683 format!("dual-bound local relay {local_url} for sister routing"),
10684 ),
10685 Err(e) => step("local-slot", format!("skipped local relay: {e}")),
10686 }
10687 } else {
10688 step(
10689 "local-slot",
10690 format!(
10691 "no local relay reachable at {local_url} — federation only \
10692 (sisters resolve via session-list)"
10693 ),
10694 );
10695 }
10696 }
10697
10698 match crate::ensure_up::ensure_daemon_running() {
10700 Ok(true) => step("daemon", "started fresh background daemon".to_string()),
10701 Ok(false) => step("daemon", "already running".to_string()),
10702 Err(e) => step(
10703 "daemon",
10704 format!("WARNING: could not start daemon: {e}. Run `wire daemon &` manually."),
10705 ),
10706 }
10707
10708 let summary =
10710 "ready. `wire pair <peer>@<relay>` to pair, `wire send <peer> \"<msg>\"` to send, \
10711 `wire monitor` to watch incoming events."
10712 .to_string();
10713 step("ready", summary.clone());
10714
10715 if as_json {
10716 let steps_json: Vec<_> = report
10717 .iter()
10718 .map(|(k, v)| json!({"stage": k, "detail": v}))
10719 .collect();
10720 println!(
10721 "{}",
10722 serde_json::to_string(&json!({
10723 "nick": canonical,
10724 "relay": relay_url,
10725 "steps": steps_json,
10726 }))?
10727 );
10728 }
10729 Ok(())
10730}
10731
10732fn strip_proto(url: &str) -> String {
10734 url.trim_start_matches("https://")
10735 .trim_start_matches("http://")
10736 .to_string()
10737}
10738
10739fn cmd_pair_megacommand(
10753 handle_arg: &str,
10754 relay_override: Option<&str>,
10755 timeout_secs: u64,
10756 _as_json: bool,
10757) -> Result<()> {
10758 let parsed = crate::pair_profile::parse_handle(handle_arg)?;
10759 let peer_handle = parsed.nick.clone();
10760
10761 eprintln!("wire pair: resolving {handle_arg}...");
10762 cmd_add(
10763 handle_arg,
10764 relay_override,
10765 false,
10766 false,
10767 )?;
10768
10769 eprintln!(
10770 "wire pair: intro delivered. waiting up to {timeout_secs}s for {peer_handle} \
10771 to ack (their daemon must be running + pulling)..."
10772 );
10773
10774 let _ = run_sync_pull();
10778
10779 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
10780 let poll_interval = std::time::Duration::from_millis(500);
10781
10782 loop {
10783 let _ = run_sync_pull();
10785 let relay_state = config::read_relay_state()?;
10786 let peer_entry = relay_state
10787 .get("peers")
10788 .and_then(|p| p.get(&peer_handle))
10789 .cloned();
10790 let token = peer_entry
10791 .as_ref()
10792 .and_then(|e| e.get("slot_token"))
10793 .and_then(Value::as_str)
10794 .unwrap_or("");
10795
10796 if !token.is_empty() {
10797 let trust = config::read_trust()?;
10799 let pinned_in_trust = trust
10800 .get("agents")
10801 .and_then(|a| a.get(&peer_handle))
10802 .is_some();
10803 println!(
10804 "wire pair: paired with {peer_handle}.\n trust: {} bilateral: yes (slot_token recorded)\n next: `wire send {peer_handle} \"<msg>\"`",
10805 if pinned_in_trust {
10806 "VERIFIED"
10807 } else {
10808 "MISSING (bug)"
10809 }
10810 );
10811 return Ok(());
10812 }
10813
10814 if std::time::Instant::now() >= deadline {
10815 bail!(
10822 "wire pair: timed out after {timeout_secs}s. \
10823 peer {peer_handle} never sent pair_drop_ack. \
10824 likely causes: (a) their daemon is down — ask them to run \
10825 `wire status` and `wire daemon &`; (b) their binary is older \
10826 than 0.5.x and doesn't understand pair_drop events — ask \
10827 them to `wire upgrade`; (c) network / relay blip — re-run \
10828 `wire pair {handle_arg}` to retry."
10829 );
10830 }
10831
10832 std::thread::sleep(poll_interval);
10833 }
10834}
10835
10836fn cmd_claim(
10837 nick: &str,
10838 relay_override: Option<&str>,
10839 public_url: Option<&str>,
10840 hidden: bool,
10841 as_json: bool,
10842) -> Result<()> {
10843 if !crate::pair_profile::is_valid_nick(nick) {
10844 bail!(
10845 "phyllis: {nick:?} won't fit in the books — handles need 2-32 chars, lowercase [a-z0-9_-], not on the reserved list"
10846 );
10847 }
10848 let (_did, relay_url, slot_id, slot_token) =
10851 crate::pair_invite::ensure_self_with_relay(relay_override)?;
10852 let card = config::read_agent_card()?;
10853
10854 let client = crate::relay_client::RelayClient::new(&relay_url);
10855 let discoverable = if hidden { Some(false) } else { None };
10859 let resp =
10860 client.handle_claim_v2(nick, &slot_id, &slot_token, public_url, &card, discoverable)?;
10861
10862 if as_json {
10863 println!(
10864 "{}",
10865 serde_json::to_string(&json!({
10866 "nick": nick,
10867 "relay": relay_url,
10868 "response": resp,
10869 }))?
10870 );
10871 } else {
10872 let domain = public_url
10876 .unwrap_or(&relay_url)
10877 .trim_start_matches("https://")
10878 .trim_start_matches("http://")
10879 .trim_end_matches('/')
10880 .split('/')
10881 .next()
10882 .unwrap_or("<this-relay-domain>")
10883 .to_string();
10884 println!("claimed {nick} on {relay_url} — others can reach you at: {nick}@{domain}");
10885 println!("verify with: wire whois {nick}@{domain}");
10886 }
10887 Ok(())
10888}
10889
10890fn cmd_profile(action: ProfileAction) -> Result<()> {
10891 match action {
10892 ProfileAction::Set { field, value, json } => {
10893 let parsed: Value =
10897 serde_json::from_str(&value).unwrap_or(Value::String(value.clone()));
10898 let new_profile = crate::pair_profile::write_profile_field(&field, parsed)?;
10899 if json {
10900 println!(
10901 "{}",
10902 serde_json::to_string(&json!({
10903 "field": field,
10904 "profile": new_profile,
10905 }))?
10906 );
10907 } else {
10908 println!("profile.{field} set");
10909 }
10910 }
10911 ProfileAction::Get { json } => return cmd_whois(None, json, None),
10912 ProfileAction::Clear { field, json } => {
10913 let new_profile = crate::pair_profile::write_profile_field(&field, Value::Null)?;
10914 if json {
10915 println!(
10916 "{}",
10917 serde_json::to_string(&json!({
10918 "field": field,
10919 "cleared": true,
10920 "profile": new_profile,
10921 }))?
10922 );
10923 } else {
10924 println!("profile.{field} cleared");
10925 }
10926 }
10927 }
10928 Ok(())
10929}
10930
10931fn cmd_setup(apply: bool) -> Result<()> {
10934 use std::path::PathBuf;
10935
10936 let entry = json!({"command": "wire", "args": ["mcp"]});
10937 let entry_pretty = serde_json::to_string_pretty(&json!({"wire": &entry}))?;
10938
10939 let mut targets: Vec<(&str, PathBuf)> = Vec::new();
10942 if let Some(home) = dirs::home_dir() {
10943 targets.push(("Claude Code", home.join(".claude.json")));
10946 targets.push(("Claude Code (alt)", home.join(".config/claude/mcp.json")));
10948 #[cfg(target_os = "macos")]
10950 targets.push((
10951 "Claude Desktop (macOS)",
10952 home.join("Library/Application Support/Claude/claude_desktop_config.json"),
10953 ));
10954 #[cfg(target_os = "windows")]
10956 if let Ok(appdata) = std::env::var("APPDATA") {
10957 targets.push((
10958 "Claude Desktop (Windows)",
10959 PathBuf::from(appdata).join("Claude/claude_desktop_config.json"),
10960 ));
10961 }
10962 targets.push(("Cursor", home.join(".cursor/mcp.json")));
10964 }
10965 targets.push(("project-local (.mcp.json)", PathBuf::from(".mcp.json")));
10967
10968 println!("wire setup\n");
10969 println!("MCP server snippet (add this to your client's mcpServers):");
10970 println!();
10971 println!("{entry_pretty}");
10972 println!();
10973
10974 if !apply {
10975 println!("Probable MCP host config locations on this machine:");
10976 for (name, path) in &targets {
10977 let marker = if path.exists() {
10978 "✓ found"
10979 } else {
10980 " (would create)"
10981 };
10982 println!(" {marker:14} {name}: {}", path.display());
10983 }
10984 println!();
10985 println!("Run `wire setup --apply` to merge wire into each config above.");
10986 println!(
10987 "Existing entries with a different command keep yours unchanged unless wire's exact entry is missing."
10988 );
10989 return Ok(());
10990 }
10991
10992 let mut modified: Vec<String> = Vec::new();
10993 let mut skipped: Vec<String> = Vec::new();
10994 for (name, path) in &targets {
10995 match upsert_mcp_entry(path, "wire", &entry) {
10996 Ok(true) => modified.push(format!("✓ {name} ({})", path.display())),
10997 Ok(false) => skipped.push(format!(" {name} ({}): already configured", path.display())),
10998 Err(e) => skipped.push(format!("✗ {name} ({}): {e}", path.display())),
10999 }
11000 }
11001 if !modified.is_empty() {
11002 println!("Modified:");
11003 for line in &modified {
11004 println!(" {line}");
11005 }
11006 println!();
11007 println!("Restart the app(s) above to load wire MCP.");
11008 }
11009 if !skipped.is_empty() {
11010 println!();
11011 println!("Skipped:");
11012 for line in &skipped {
11013 println!(" {line}");
11014 }
11015 }
11016 Ok(())
11017}
11018
11019fn upsert_mcp_entry(path: &std::path::Path, server_name: &str, entry: &Value) -> Result<bool> {
11022 let mut cfg: Value = if path.exists() {
11023 let body = std::fs::read_to_string(path).context("reading config")?;
11024 serde_json::from_str(&body).unwrap_or_else(|_| json!({}))
11025 } else {
11026 json!({})
11027 };
11028 if !cfg.is_object() {
11029 cfg = json!({});
11030 }
11031 let root = cfg.as_object_mut().unwrap();
11032 let servers = root
11033 .entry("mcpServers".to_string())
11034 .or_insert_with(|| json!({}));
11035 if !servers.is_object() {
11036 *servers = json!({});
11037 }
11038 let map = servers.as_object_mut().unwrap();
11039 if map.get(server_name) == Some(entry) {
11040 return Ok(false);
11041 }
11042 map.insert(server_name.to_string(), entry.clone());
11043 if let Some(parent) = path.parent()
11044 && !parent.as_os_str().is_empty()
11045 {
11046 std::fs::create_dir_all(parent).context("creating parent dir")?;
11047 }
11048 let out = serde_json::to_string_pretty(&cfg)? + "\n";
11049 std::fs::write(path, out).context("writing config")?;
11050 Ok(true)
11051}
11052
11053#[allow(clippy::too_many_arguments)]
11056fn cmd_reactor(
11057 on_event: &str,
11058 peer_filter: Option<&str>,
11059 kind_filter: Option<&str>,
11060 verified_only: bool,
11061 interval_secs: u64,
11062 once: bool,
11063 dry_run: bool,
11064 max_per_minute: u32,
11065 max_chain_depth: u32,
11066) -> Result<()> {
11067 use crate::inbox_watch::{InboxEvent, InboxWatcher};
11068 use std::collections::{HashMap, HashSet, VecDeque};
11069 use std::io::Write;
11070 use std::process::{Command, Stdio};
11071 use std::time::{Duration, Instant};
11072
11073 let cursor_path = config::state_dir()?.join("reactor.cursor");
11074 let emitted_path = config::state_dir()?.join("reactor-emitted.log");
11083 let mut emitted_ids: HashSet<String> = HashSet::new();
11084 if emitted_path.exists()
11085 && let Ok(body) = std::fs::read_to_string(&emitted_path)
11086 {
11087 for line in body.lines() {
11088 let t = line.trim();
11089 if !t.is_empty() {
11090 emitted_ids.insert(t.to_string());
11091 }
11092 }
11093 }
11094 let outbox_dir = config::outbox_dir()?;
11096 let mut outbox_cursors: HashMap<String, u64> = HashMap::new();
11099
11100 let mut watcher = InboxWatcher::from_cursor_file(&cursor_path)?;
11101
11102 let kind_num: Option<u32> = match kind_filter {
11103 Some(k) => Some(parse_kind(k)?),
11104 None => None,
11105 };
11106
11107 let mut peer_dispatch_log: HashMap<String, VecDeque<Instant>> = HashMap::new();
11109
11110 let dispatch = |ev: &InboxEvent,
11111 peer_dispatch_log: &mut HashMap<String, VecDeque<Instant>>,
11112 emitted_ids: &HashSet<String>|
11113 -> Result<bool> {
11114 if let Some(p) = peer_filter
11115 && ev.peer != p
11116 {
11117 return Ok(false);
11118 }
11119 if verified_only && !ev.verified {
11120 return Ok(false);
11121 }
11122 if let Some(want) = kind_num {
11123 let ev_kind = ev.raw.get("kind").and_then(Value::as_u64).map(|n| n as u32);
11124 if ev_kind != Some(want) {
11125 return Ok(false);
11126 }
11127 }
11128
11129 if max_chain_depth > 0 {
11133 let body_str = match &ev.raw["body"] {
11134 Value::String(s) => s.clone(),
11135 other => serde_json::to_string(other).unwrap_or_default(),
11136 };
11137 if let Some(referenced) = parse_re_marker(&body_str) {
11138 let matched = emitted_ids.contains(&referenced)
11141 || emitted_ids.iter().any(|full| full.starts_with(&referenced));
11142 if matched {
11143 eprintln!(
11144 "wire reactor: skip {} from {} — chain-depth (reply to our re:{})",
11145 ev.event_id, ev.peer, referenced
11146 );
11147 return Ok(false);
11148 }
11149 }
11150 }
11151
11152 if max_per_minute > 0 {
11154 let now = Instant::now();
11155 let win = peer_dispatch_log.entry(ev.peer.clone()).or_default();
11156 while let Some(&front) = win.front() {
11157 if now.duration_since(front) > Duration::from_secs(60) {
11158 win.pop_front();
11159 } else {
11160 break;
11161 }
11162 }
11163 if win.len() as u32 >= max_per_minute {
11164 eprintln!(
11165 "wire reactor: skip {} from {} — rate-limit ({}/min reached)",
11166 ev.event_id, ev.peer, max_per_minute
11167 );
11168 return Ok(false);
11169 }
11170 win.push_back(now);
11171 }
11172
11173 if dry_run {
11174 println!("{}", serde_json::to_string(&ev.raw)?);
11175 return Ok(true);
11176 }
11177
11178 let mut child = Command::new("sh")
11179 .arg("-c")
11180 .arg(on_event)
11181 .stdin(Stdio::piped())
11182 .stdout(Stdio::inherit())
11183 .stderr(Stdio::inherit())
11184 .env("WIRE_EVENT_PEER", &ev.peer)
11185 .env("WIRE_EVENT_ID", &ev.event_id)
11186 .env("WIRE_EVENT_KIND", &ev.kind)
11187 .spawn()
11188 .with_context(|| format!("spawning reactor handler: {on_event}"))?;
11189 if let Some(mut stdin) = child.stdin.take() {
11190 let body = serde_json::to_vec(&ev.raw)?;
11191 let _ = stdin.write_all(&body);
11192 let _ = stdin.write_all(b"\n");
11193 }
11194 std::mem::drop(child);
11195 Ok(true)
11196 };
11197
11198 let scan_outbox = |emitted_ids: &mut HashSet<String>,
11200 outbox_cursors: &mut HashMap<String, u64>|
11201 -> Result<usize> {
11202 if !outbox_dir.exists() {
11203 return Ok(0);
11204 }
11205 let mut added = 0;
11206 let mut new_ids: Vec<String> = Vec::new();
11207 for entry in std::fs::read_dir(&outbox_dir)?.flatten() {
11208 let path = entry.path();
11209 if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
11210 continue;
11211 }
11212 let peer = match path.file_stem().and_then(|s| s.to_str()) {
11213 Some(s) => s.to_string(),
11214 None => continue,
11215 };
11216 let cur_len = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
11217 let start = *outbox_cursors.get(&peer).unwrap_or(&0);
11218 if cur_len <= start {
11219 outbox_cursors.insert(peer, start);
11220 continue;
11221 }
11222 let body = std::fs::read_to_string(&path).unwrap_or_default();
11223 let tail = &body[start as usize..];
11224 for line in tail.lines() {
11225 if let Ok(v) = serde_json::from_str::<Value>(line)
11226 && let Some(eid) = v.get("event_id").and_then(Value::as_str)
11227 && emitted_ids.insert(eid.to_string())
11228 {
11229 new_ids.push(eid.to_string());
11230 added += 1;
11231 }
11232 }
11233 outbox_cursors.insert(peer, cur_len);
11234 }
11235 if !new_ids.is_empty() {
11236 let mut all: Vec<String> = emitted_ids.iter().cloned().collect();
11238 if all.len() > 500 {
11239 all.sort();
11240 let drop_n = all.len() - 500;
11241 let dropped: HashSet<String> = all.iter().take(drop_n).cloned().collect();
11242 emitted_ids.retain(|x| !dropped.contains(x));
11243 all = emitted_ids.iter().cloned().collect();
11244 }
11245 let _ = std::fs::write(&emitted_path, all.join("\n") + "\n");
11246 }
11247 Ok(added)
11248 };
11249
11250 let sweep = |watcher: &mut InboxWatcher,
11251 emitted_ids: &mut HashSet<String>,
11252 outbox_cursors: &mut HashMap<String, u64>,
11253 peer_dispatch_log: &mut HashMap<String, VecDeque<Instant>>|
11254 -> Result<usize> {
11255 let _ = scan_outbox(emitted_ids, outbox_cursors);
11257
11258 let events = watcher.poll()?;
11259 let mut fired = 0usize;
11260 for ev in &events {
11261 match dispatch(ev, peer_dispatch_log, emitted_ids) {
11262 Ok(true) => fired += 1,
11263 Ok(false) => {}
11264 Err(e) => eprintln!("wire reactor: handler error for {}: {e}", ev.event_id),
11265 }
11266 }
11267 watcher.save_cursors(&cursor_path)?;
11268 Ok(fired)
11269 };
11270
11271 if once {
11272 sweep(
11273 &mut watcher,
11274 &mut emitted_ids,
11275 &mut outbox_cursors,
11276 &mut peer_dispatch_log,
11277 )?;
11278 return Ok(());
11279 }
11280 let interval = std::time::Duration::from_secs(interval_secs.max(1));
11281 loop {
11282 if let Err(e) = sweep(
11283 &mut watcher,
11284 &mut emitted_ids,
11285 &mut outbox_cursors,
11286 &mut peer_dispatch_log,
11287 ) {
11288 eprintln!("wire reactor: sweep error: {e}");
11289 }
11290 std::thread::sleep(interval);
11291 }
11292}
11293
11294fn parse_re_marker(body: &str) -> Option<String> {
11297 let needle = "(re:";
11298 let i = body.find(needle)?;
11299 let rest = &body[i + needle.len()..];
11300 let end = rest.find(')')?;
11301 let id = rest[..end].trim().to_string();
11302 if id.is_empty() {
11303 return None;
11304 }
11305 Some(id)
11306}
11307
11308fn cmd_notify(
11311 interval_secs: u64,
11312 peer_filter: Option<&str>,
11313 once: bool,
11314 as_json: bool,
11315) -> Result<()> {
11316 use crate::inbox_watch::InboxWatcher;
11317 let cursor_path = config::state_dir()?.join("notify.cursor");
11318 let mut watcher = InboxWatcher::from_cursor_file(&cursor_path)?;
11319
11320 let sweep = |watcher: &mut InboxWatcher| -> Result<()> {
11321 let events = watcher.poll()?;
11322 for ev in events {
11323 if let Some(p) = peer_filter
11324 && ev.peer != p
11325 {
11326 continue;
11327 }
11328 if as_json {
11329 println!("{}", serde_json::to_string(&ev)?);
11330 } else {
11331 os_notify_inbox_event(&ev);
11332 }
11333 }
11334 watcher.save_cursors(&cursor_path)?;
11335 Ok(())
11336 };
11337
11338 if once {
11339 return sweep(&mut watcher);
11340 }
11341
11342 let interval = std::time::Duration::from_secs(interval_secs.max(1));
11343 loop {
11344 if let Err(e) = sweep(&mut watcher) {
11345 eprintln!("wire notify: sweep error: {e}");
11346 }
11347 std::thread::sleep(interval);
11348 }
11349}
11350
11351fn os_notify_inbox_event(ev: &crate::inbox_watch::InboxEvent) {
11352 let who = persona_label(&ev.peer);
11353 let title = if ev.verified {
11354 format!("wire ← {who}")
11355 } else {
11356 format!("wire ← {who} (UNVERIFIED)")
11357 };
11358 let body = format!("{}: {}", ev.kind, ev.body_preview);
11359 crate::os_notify::toast(&title, &body);
11360}
11361
11362#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
11363fn os_toast(title: &str, body: &str) {
11364 eprintln!("[wire notify] {title}\n {body}");
11365}
11366
11367