1use anyhow::{Context, Result, anyhow, bail};
17use clap::{Parser, Subcommand};
18use serde_json::{Value, json};
19
20use crate::{
21 agent_card::{build_agent_card, sign_agent_card},
22 config,
23 signing::{fingerprint, generate_keypair, make_key_id, sign_message_v31, verify_message_v31},
24 trust::{add_self_to_trust, empty_trust},
25};
26
27#[derive(Parser, Debug)]
29#[command(name = "wire", version, about = "Magic-wormhole for AI agents — bilateral signed-message bus", long_about = None)]
30pub struct Cli {
31 #[command(subcommand)]
32 pub command: Command,
33}
34
35#[derive(Subcommand, Debug)]
36pub enum Command {
37 #[command(hide = true)]
53 Init {
54 handle: String,
56 #[arg(long)]
58 name: Option<String>,
59 #[arg(long)]
64 relay: Option<String>,
65 #[arg(long, conflicts_with = "relay")]
70 offline: bool,
71 #[arg(long)]
73 json: bool,
74 },
75 Whoami {
79 #[arg(long)]
80 json: bool,
81 #[arg(long, conflicts_with = "json")]
84 short: bool,
85 #[arg(long, conflicts_with_all = ["json", "short"])]
88 colored: bool,
89 },
90 Peers {
92 #[arg(long)]
93 json: bool,
94 },
95 Completions {
106 #[arg(value_enum)]
108 shell: clap_complete::Shell,
109 },
110 Here {
118 #[arg(long)]
119 json: bool,
120 },
121 Pending {
126 #[arg(long)]
127 json: bool,
128 },
129 Send {
137 peer: String,
139 kind_or_body: String,
144 body: Option<String>,
148 #[arg(long)]
150 deadline: Option<String>,
151 #[arg(long)]
156 no_auto_pair: bool,
157 #[arg(long)]
159 json: bool,
160 },
161 Dial {
178 name: String,
182 message: Option<String>,
186 #[arg(long)]
188 json: bool,
189 },
190 Tail {
192 peer: Option<String>,
194 #[arg(long)]
196 json: bool,
197 #[arg(long, default_value_t = 0)]
199 limit: usize,
200 },
201 Monitor {
212 #[arg(long)]
214 peer: Option<String>,
215 #[arg(long)]
217 json: bool,
218 #[arg(long)]
221 include_handshake: bool,
222 #[arg(long, default_value_t = 500)]
224 interval_ms: u64,
225 #[arg(long, default_value_t = 0)]
227 replay: usize,
228 },
229 Verify {
231 path: String,
233 #[arg(long)]
235 json: bool,
236 },
237 Mcp,
241 RelayServer {
243 #[arg(long, default_value = "127.0.0.1:8770")]
245 bind: String,
246 #[arg(long)]
254 local_only: bool,
255 #[arg(long)]
261 uds: Option<std::path::PathBuf>,
262 },
263 BindRelay {
272 url: String,
274 #[arg(long)]
280 scope: Option<String>,
281 #[arg(long)]
287 replace: bool,
288 #[arg(long)]
294 migrate_pinned: bool,
295 #[arg(long)]
296 json: bool,
297 },
298 AddPeerSlot {
301 handle: String,
303 url: String,
305 slot_id: String,
307 slot_token: String,
309 #[arg(long)]
310 json: bool,
311 },
312 Push {
314 peer: Option<String>,
316 #[arg(long)]
317 json: bool,
318 },
319 Pull {
321 #[arg(long)]
322 json: bool,
323 },
324 Status {
327 #[arg(long)]
329 peer: Option<String>,
330 #[arg(long)]
331 json: bool,
332 },
333 Responder {
335 #[command(subcommand)]
336 command: ResponderCommand,
337 },
338 Pin {
341 card_file: String,
343 #[arg(long)]
344 json: bool,
345 },
346 RotateSlot {
357 #[arg(long)]
360 no_announce: bool,
361 #[arg(long)]
362 json: bool,
363 },
364 ForgetPeer {
368 handle: String,
370 #[arg(long)]
372 purge: bool,
373 #[arg(long)]
374 json: bool,
375 },
376 Daemon {
380 #[arg(long, default_value_t = 5)]
382 interval: u64,
383 #[arg(long)]
385 once: bool,
386 #[arg(long)]
387 json: bool,
388 },
389 #[command(hide = true)] PairHost {
395 #[arg(long)]
397 relay: String,
398 #[arg(long)]
402 yes: bool,
403 #[arg(long, default_value_t = 300)]
405 timeout: u64,
406 #[arg(long)]
412 detach: bool,
413 #[arg(long)]
415 json: bool,
416 },
417 #[command(alias = "join")]
421 #[command(hide = true)] PairJoin {
423 code_phrase: String,
425 #[arg(long)]
427 relay: String,
428 #[arg(long)]
429 yes: bool,
430 #[arg(long, default_value_t = 300)]
431 timeout: u64,
432 #[arg(long)]
434 detach: bool,
435 #[arg(long)]
437 json: bool,
438 },
439 #[command(hide = true)] PairConfirm {
444 code_phrase: String,
446 digits: String,
448 #[arg(long)]
450 json: bool,
451 },
452 #[command(hide = true)] PairList {
455 #[arg(long)]
457 json: bool,
458 #[arg(long)]
462 watch: bool,
463 #[arg(long, default_value_t = 1)]
465 watch_interval: u64,
466 },
467 #[command(hide = true)] PairCancel {
470 code_phrase: String,
471 #[arg(long)]
472 json: bool,
473 },
474 #[command(hide = true)] PairWatch {
485 code_phrase: String,
486 #[arg(long, default_value = "sas_ready")]
488 status: String,
489 #[arg(long, default_value_t = 300)]
491 timeout: u64,
492 #[arg(long)]
494 json: bool,
495 },
496 #[command(hide = true)] Pair {
510 handle: String,
513 #[arg(long)]
516 code: Option<String>,
517 #[arg(long, default_value = "https://wireup.net")]
519 relay: String,
520 #[arg(long)]
522 yes: bool,
523 #[arg(long, default_value_t = 300)]
525 timeout: u64,
526 #[arg(long)]
529 no_setup: bool,
530 #[arg(long)]
535 detach: bool,
536 },
537 #[command(hide = true)] PairAbandon {
544 code_phrase: String,
546 #[arg(long, default_value = "https://wireup.net")]
548 relay: String,
549 },
550 #[command(hide = true)] PairAccept {
557 peer: String,
559 #[arg(long)]
561 json: bool,
562 },
563 #[command(hide = true)] PairReject {
571 peer: String,
573 #[arg(long)]
575 json: bool,
576 },
577 #[command(hide = true)] PairListInbound {
584 #[arg(long)]
586 json: bool,
587 },
588 #[command(subcommand)]
598 Session(SessionCommand),
599 Identity {
604 #[command(subcommand)]
605 cmd: IdentityCommand,
606 },
607 #[command(subcommand)]
612 Mesh(MeshCommand),
613 Setup {
618 #[arg(long)]
620 apply: bool,
621 #[arg(long)]
627 statusline: bool,
628 #[arg(long)]
631 remove: bool,
632 },
633 Whois {
637 handle: Option<String>,
639 #[arg(long)]
640 json: bool,
641 #[arg(long)]
644 relay: Option<String>,
645 },
646 Add {
652 handle: String,
655 #[arg(long)]
657 relay: Option<String>,
658 #[arg(long)]
666 local_sister: bool,
667 #[arg(long)]
668 json: bool,
669 },
670 Up {
683 relay: Option<String>,
687 #[arg(long)]
690 name: Option<String>,
691 #[arg(long)]
696 with_local: Option<String>,
697 #[arg(long)]
699 no_local: bool,
700 #[arg(long)]
701 json: bool,
702 },
703 Doctor {
710 #[arg(long)]
712 json: bool,
713 #[arg(long, default_value_t = 5)]
715 recent_rejections: usize,
716 },
717 Upgrade {
722 #[arg(long)]
725 check: bool,
726 #[arg(long)]
727 json: bool,
728 },
729 Service {
734 #[command(subcommand)]
735 action: ServiceAction,
736 },
737 Diag {
742 #[command(subcommand)]
743 action: DiagAction,
744 },
745 #[command(hide = true)]
757 Claim {
758 nick: String,
760 #[arg(long)]
762 relay: Option<String>,
763 #[arg(long)]
765 public_url: Option<String>,
766 #[arg(long)]
774 hidden: bool,
775 #[arg(long)]
776 json: bool,
777 },
778 Profile {
788 #[command(subcommand)]
789 action: ProfileAction,
790 },
791 #[command(hide = true)] Invite {
796 #[arg(long, default_value = "https://wireup.net")]
798 relay: String,
799 #[arg(long, default_value_t = 86_400)]
801 ttl: u64,
802 #[arg(long, default_value_t = 1)]
805 uses: u32,
806 #[arg(long)]
810 share: bool,
811 #[arg(long)]
813 json: bool,
814 },
815 Accept {
825 target: String,
827 #[arg(long)]
829 json: bool,
830 },
831 #[command(alias = "invite-accept")]
839 AcceptInvite {
840 url: String,
842 #[arg(long)]
844 json: bool,
845 },
846 Reject {
849 peer: String,
851 #[arg(long)]
853 json: bool,
854 },
855 Notify {
860 #[arg(long, default_value_t = 2)]
862 interval: u64,
863 #[arg(long)]
865 peer: Option<String>,
866 #[arg(long)]
868 once: bool,
869 #[arg(long)]
873 json: bool,
874 },
875}
876
877#[derive(Subcommand, Debug)]
878pub enum DiagAction {
879 Tail {
881 #[arg(long, default_value_t = 20)]
882 limit: usize,
883 #[arg(long)]
884 json: bool,
885 },
886 Enable,
889 Disable,
891 Status {
893 #[arg(long)]
894 json: bool,
895 },
896}
897
898#[derive(Subcommand, Debug)]
899pub enum IdentityCommand {
900 Show {
903 #[arg(long)]
904 json: bool,
905 },
906 List {
911 #[arg(long)]
912 json: bool,
913 },
914 #[command(hide = true)]
922 Publish {
923 nick: String,
925 #[arg(long)]
928 relay: Option<String>,
929 #[arg(long, alias = "public")]
932 public_url: Option<String>,
933 #[arg(long)]
937 hidden: bool,
938 #[arg(long)]
939 json: bool,
940 },
941 Destroy {
945 name: String,
947 #[arg(long)]
949 force: bool,
950 #[arg(long)]
951 json: bool,
952 },
953 Create {
965 #[arg(long)]
968 name: Option<String>,
969 #[arg(long, conflicts_with = "local")]
972 anonymous: bool,
973 #[arg(long)]
976 local: bool,
977 #[arg(long)]
978 json: bool,
979 },
980 Persist {
985 name: String,
987 #[arg(long = "as", value_name = "NEW_NAME")]
989 as_name: Option<String>,
990 #[arg(long)]
991 json: bool,
992 },
993 Demote {
1003 name: String,
1005 #[arg(long)]
1006 json: bool,
1007 },
1008}
1009
1010#[derive(Subcommand, Debug)]
1011pub enum SessionCommand {
1012 New {
1020 name: Option<String>,
1022 #[arg(long, default_value = "https://wireup.net")]
1024 relay: String,
1025 #[arg(long)]
1032 with_local: bool,
1033 #[arg(long, default_value = "http://127.0.0.1:8771")]
1037 local_relay: String,
1038 #[arg(long)]
1045 with_lan: bool,
1046 #[arg(long)]
1050 lan_relay: Option<String>,
1051 #[arg(long)]
1058 with_uds: bool,
1059 #[arg(long)]
1063 uds_socket: Option<std::path::PathBuf>,
1064 #[arg(long)]
1067 no_daemon: bool,
1068 #[arg(long)]
1076 local_only: bool,
1077 #[arg(long)]
1079 json: bool,
1080 },
1081 List {
1084 #[arg(long)]
1085 json: bool,
1086 },
1087 ListLocal {
1093 #[arg(long)]
1094 json: bool,
1095 },
1096 PairAllLocal {
1112 #[arg(long, default_value_t = 1)]
1117 settle_secs: u64,
1118 #[arg(long, default_value = "https://wireup.net")]
1123 federation_relay: String,
1124 #[arg(long)]
1125 json: bool,
1126 },
1127 MeshStatus {
1141 #[arg(long, default_value_t = 300)]
1146 stale_secs: u64,
1147 #[arg(long)]
1148 json: bool,
1149 },
1150 Env {
1154 name: Option<String>,
1156 #[arg(long)]
1157 json: bool,
1158 },
1159 Current {
1163 #[arg(long)]
1164 json: bool,
1165 },
1166 Bind {
1174 name: Option<String>,
1178 #[arg(long)]
1179 json: bool,
1180 },
1181 Destroy {
1185 name: String,
1186 #[arg(long)]
1188 force: bool,
1189 #[arg(long)]
1190 json: bool,
1191 },
1192}
1193
1194#[derive(Subcommand, Debug)]
1199pub enum MeshCommand {
1200 Status {
1203 #[arg(long, default_value_t = 300)]
1205 stale_secs: u64,
1206 #[arg(long)]
1207 json: bool,
1208 },
1209 Broadcast {
1228 #[arg(long, default_value = "claim")]
1231 kind: String,
1232 #[arg(long, default_value = "local")]
1234 scope: String,
1235 #[arg(long)]
1237 exclude: Vec<String>,
1238 #[arg(long)]
1242 noreply: bool,
1243 body: String,
1245 #[arg(long)]
1246 json: bool,
1247 },
1248 Role {
1257 #[command(subcommand)]
1258 action: MeshRoleAction,
1259 },
1260 Route {
1276 role: String,
1278 #[arg(long, default_value = "round-robin")]
1280 strategy: String,
1281 #[arg(long)]
1283 exclude: Vec<String>,
1284 #[arg(long, default_value = "claim")]
1287 kind: String,
1288 body: String,
1290 #[arg(long)]
1291 json: bool,
1292 },
1293}
1294
1295#[derive(Subcommand, Debug)]
1297pub enum MeshRoleAction {
1298 Set {
1303 role: String,
1304 #[arg(long)]
1305 json: bool,
1306 },
1307 Get {
1310 peer: Option<String>,
1311 #[arg(long)]
1312 json: bool,
1313 },
1314 List {
1317 #[arg(long)]
1318 json: bool,
1319 },
1320 Clear {
1323 #[arg(long)]
1324 json: bool,
1325 },
1326}
1327
1328#[derive(Subcommand, Debug)]
1329pub enum ServiceAction {
1330 Install {
1340 #[arg(long)]
1342 local_relay: bool,
1343 #[arg(long)]
1344 json: bool,
1345 },
1346 Uninstall {
1350 #[arg(long)]
1352 local_relay: bool,
1353 #[arg(long)]
1354 json: bool,
1355 },
1356 Status {
1358 #[arg(long)]
1360 local_relay: bool,
1361 #[arg(long)]
1362 json: bool,
1363 },
1364}
1365
1366#[derive(Subcommand, Debug)]
1367pub enum ResponderCommand {
1368 Set {
1370 status: String,
1372 #[arg(long)]
1374 reason: Option<String>,
1375 #[arg(long)]
1377 json: bool,
1378 },
1379 Get {
1381 peer: Option<String>,
1383 #[arg(long)]
1385 json: bool,
1386 },
1387}
1388
1389#[derive(Subcommand, Debug)]
1390pub enum ProfileAction {
1391 Set {
1395 field: String,
1396 value: String,
1397 #[arg(long)]
1398 json: bool,
1399 },
1400 Get {
1402 #[arg(long)]
1403 json: bool,
1404 },
1405 Clear {
1407 field: String,
1408 #[arg(long)]
1409 json: bool,
1410 },
1411}
1412
1413pub fn run() -> Result<()> {
1415 crate::session::maybe_adopt_session_wire_home("cli");
1426 let cli = Cli::parse();
1427 match cli.command {
1428 Command::Init {
1429 handle,
1430 name,
1431 relay,
1432 offline,
1433 json,
1434 } => cmd_init(
1435 Some(&handle),
1436 name.as_deref(),
1437 relay.as_deref(),
1438 offline,
1439 json,
1440 ),
1441 Command::Status { peer, json } => {
1442 if let Some(peer) = peer {
1443 cmd_status_peer(&peer, json)
1444 } else {
1445 cmd_status(json)
1446 }
1447 }
1448 Command::Whoami {
1449 json,
1450 short,
1451 colored,
1452 } => cmd_whoami(json_default(json), short, colored),
1453 Command::Peers { json } => cmd_peers(json_default(json)),
1454 Command::Here { json } => cmd_here(json_default(json)),
1455 Command::Completions { shell } => {
1456 use clap::CommandFactory;
1463 let mut cmd = Cli::command();
1464 clap_complete::generate(shell, &mut cmd, "wire", &mut std::io::stdout());
1465 Ok(())
1466 }
1467 Command::Pending { json } => cmd_pair_list_inbound(json_default(json)),
1468 Command::Reject { peer, json } => cmd_pair_reject(&peer, json_default(json)),
1469 Command::Send {
1470 peer,
1471 kind_or_body,
1472 body,
1473 deadline,
1474 no_auto_pair,
1475 json,
1476 } => {
1477 let (kind, body) = match body {
1480 Some(real_body) => (kind_or_body, real_body),
1481 None => ("claim".to_string(), kind_or_body),
1482 };
1483 cmd_send(
1484 &peer,
1485 &kind,
1486 &body,
1487 deadline.as_deref(),
1488 no_auto_pair,
1489 json_default(json),
1490 )
1491 }
1492 Command::Dial {
1493 name,
1494 message,
1495 json,
1496 } => cmd_dial(&name, message.as_deref(), json_default(json)),
1497 Command::Tail { peer, json, limit } => cmd_tail(peer.as_deref(), json, limit),
1498 Command::Monitor {
1499 peer,
1500 json,
1501 include_handshake,
1502 interval_ms,
1503 replay,
1504 } => cmd_monitor(
1505 peer.as_deref(),
1506 json,
1507 include_handshake,
1508 interval_ms,
1509 replay,
1510 ),
1511 Command::Verify { path, json } => cmd_verify(&path, json),
1512 Command::Responder { command } => match command {
1513 ResponderCommand::Set {
1514 status,
1515 reason,
1516 json,
1517 } => cmd_responder_set(&status, reason.as_deref(), json),
1518 ResponderCommand::Get { peer, json } => cmd_responder_get(peer.as_deref(), json),
1519 },
1520 Command::Mcp => cmd_mcp(),
1521 Command::RelayServer {
1522 bind,
1523 local_only,
1524 uds,
1525 } => cmd_relay_server(&bind, local_only, uds.as_deref()),
1526 Command::BindRelay {
1527 url,
1528 scope,
1529 replace,
1530 migrate_pinned,
1531 json,
1532 } => cmd_bind_relay(&url, scope.as_deref(), replace, migrate_pinned, json),
1533 Command::AddPeerSlot {
1534 handle,
1535 url,
1536 slot_id,
1537 slot_token,
1538 json,
1539 } => cmd_add_peer_slot(&handle, &url, &slot_id, &slot_token, json),
1540 Command::Push { peer, json } => cmd_push(peer.as_deref(), json),
1541 Command::Pull { json } => cmd_pull(json),
1542 Command::Pin { card_file, json } => cmd_pin(&card_file, json),
1543 Command::RotateSlot { no_announce, json } => cmd_rotate_slot(no_announce, json),
1544 Command::ForgetPeer {
1545 handle,
1546 purge,
1547 json,
1548 } => cmd_forget_peer(&handle, purge, json),
1549 Command::Daemon {
1550 interval,
1551 once,
1552 json,
1553 } => cmd_daemon(interval, once, json),
1554 Command::PairHost {
1555 relay,
1556 yes,
1557 timeout,
1558 detach,
1559 json,
1560 } => {
1561 if detach {
1562 cmd_pair_host_detach(&relay, json)
1563 } else {
1564 cmd_pair_host(&relay, yes, timeout)
1565 }
1566 }
1567 Command::PairJoin {
1568 code_phrase,
1569 relay,
1570 yes,
1571 timeout,
1572 detach,
1573 json,
1574 } => {
1575 if detach {
1576 cmd_pair_join_detach(&code_phrase, &relay, json)
1577 } else {
1578 cmd_pair_join(&code_phrase, &relay, yes, timeout)
1579 }
1580 }
1581 Command::PairConfirm {
1582 code_phrase,
1583 digits,
1584 json,
1585 } => cmd_pair_confirm(&code_phrase, &digits, json),
1586 Command::PairList {
1587 json,
1588 watch,
1589 watch_interval,
1590 } => cmd_pair_list(json, watch, watch_interval),
1591 Command::PairCancel { code_phrase, json } => cmd_pair_cancel(&code_phrase, json),
1592 Command::PairWatch {
1593 code_phrase,
1594 status,
1595 timeout,
1596 json,
1597 } => cmd_pair_watch(&code_phrase, &status, timeout, json),
1598 Command::Pair {
1599 handle,
1600 code,
1601 relay,
1602 yes,
1603 timeout,
1604 no_setup,
1605 detach,
1606 } => {
1607 if handle.contains('@') && code.is_none() {
1614 cmd_pair_megacommand(&handle, Some(&relay), timeout, false)
1615 } else if detach {
1616 cmd_pair_detach(&handle, code.as_deref(), &relay)
1617 } else {
1618 cmd_pair(&handle, code.as_deref(), &relay, yes, timeout, no_setup)
1619 }
1620 }
1621 Command::PairAbandon { code_phrase, relay } => cmd_pair_abandon(&code_phrase, &relay),
1622 Command::PairAccept { peer, json } => {
1623 let j = json_default(json);
1624 deprecation_warn("pair-accept", &format!("accept {peer}"), j);
1625 cmd_pair_accept(&peer, j)
1626 }
1627 Command::PairReject { peer, json } => {
1628 let j = json_default(json);
1629 deprecation_warn("pair-reject", &format!("reject {peer}"), j);
1630 cmd_pair_reject(&peer, j)
1631 }
1632 Command::PairListInbound { json } => {
1633 let j = json_default(json);
1634 deprecation_warn("pair-list-inbound", "pending", j);
1635 cmd_pair_list_inbound(j)
1636 }
1637 Command::Session(cmd) => cmd_session(cmd),
1638 Command::Identity { cmd } => cmd_identity(cmd),
1639 Command::Mesh(cmd) => cmd_mesh(cmd),
1640 Command::Invite {
1641 relay,
1642 ttl,
1643 uses,
1644 share,
1645 json,
1646 } => cmd_invite(&relay, ttl, uses, share, json),
1647 Command::Accept { target, json } => {
1648 let j = json_default(json);
1654 if target.starts_with("wire://pair?") {
1655 deprecation_warn("accept-url", "accept-invite <url>", j);
1656 cmd_accept(&target, j)
1657 } else {
1658 cmd_pair_accept(&target, j)
1659 }
1660 }
1661 Command::AcceptInvite { url, json } => cmd_accept(&url, json_default(json)),
1662 Command::Whois {
1663 handle,
1664 json,
1665 relay,
1666 } => {
1667 match handle.as_deref() {
1676 Some(h) if !h.contains('@') => cmd_whois_local(h, json),
1677 other => cmd_whois(other, json, relay.as_deref()),
1678 }
1679 }
1680 Command::Add {
1681 handle,
1682 relay,
1683 local_sister,
1684 json,
1685 } => cmd_add(&handle, relay.as_deref(), local_sister, json),
1686 Command::Up {
1687 relay,
1688 name,
1689 with_local,
1690 no_local,
1691 json,
1692 } => cmd_up(
1693 relay.as_deref(),
1694 name.as_deref(),
1695 with_local.as_deref(),
1696 no_local,
1697 json,
1698 ),
1699 Command::Doctor {
1700 json,
1701 recent_rejections,
1702 } => cmd_doctor(json, recent_rejections),
1703 Command::Upgrade { check, json } => cmd_upgrade(check, json),
1704 Command::Service { action } => cmd_service(action),
1705 Command::Diag { action } => cmd_diag(action),
1706 Command::Claim {
1707 nick,
1708 relay,
1709 public_url,
1710 hidden,
1711 json,
1712 } => cmd_claim(&nick, relay.as_deref(), public_url.as_deref(), hidden, json),
1713 Command::Profile { action } => cmd_profile(action),
1714 Command::Setup {
1715 apply,
1716 statusline,
1717 remove,
1718 } => {
1719 if statusline {
1720 cmd_setup_statusline(apply, remove)
1721 } else {
1722 cmd_setup(apply)
1723 }
1724 }
1725 Command::Notify {
1726 interval,
1727 peer,
1728 once,
1729 json,
1730 } => cmd_notify(interval, peer.as_deref(), once, json),
1731 }
1732}
1733
1734fn cmd_init(
1737 handle: Option<&str>,
1738 name: Option<&str>,
1739 relay: Option<&str>,
1740 offline: bool,
1741 as_json: bool,
1742) -> Result<()> {
1743 if let Some(h) = handle
1749 && !h
1750 .chars()
1751 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
1752 {
1753 bail!("handle must be ASCII alphanumeric / '-' / '_' (got {h:?})");
1754 }
1755 if config::is_initialized()? {
1756 bail!(
1757 "already initialized — config exists at {:?}. Delete it first if you want a fresh identity.",
1758 config::config_dir()?
1759 );
1760 }
1761 let mut resolved_relay: Option<String> = relay.map(str::to_string);
1775 if resolved_relay.is_none() && !offline {
1776 let default_local = "http://127.0.0.1:8771";
1777 let client = crate::relay_client::RelayClient::new(default_local);
1778 if client.check_healthz().is_ok() {
1779 eprintln!(
1780 "wire init: local relay at {default_local} reachable — auto-attaching. \
1781 Use --relay <url> to pick a different relay, --offline to skip."
1782 );
1783 resolved_relay = Some(default_local.to_string());
1784 } else {
1785 use std::io::{BufRead, IsTerminal, Write};
1791 let interactive = std::io::stdin().is_terminal() && std::io::stderr().is_terminal();
1792 if interactive && std::env::var("WIRE_NO_INTERACTIVE").is_err() {
1793 eprintln!("wire init: no local relay reachable at {default_local}.");
1794 eprint!(
1795 " Bind to public federation relay https://wireup.net instead? \
1796 [Y/n/offline/url]: "
1797 );
1798 let _ = std::io::stderr().flush();
1799 let mut input = String::new();
1800 let _ = std::io::stdin().lock().read_line(&mut input);
1801 let answer = input.trim();
1802 match answer {
1803 "" | "y" | "Y" | "yes" | "YES" => {
1804 eprintln!("wire init: binding to https://wireup.net");
1805 resolved_relay = Some("https://wireup.net".to_string());
1806 }
1807 "n" | "N" | "no" | "NO" => {
1808 bail!(
1809 "wire init: declined federation default; re-run with --relay <url> or --offline."
1810 );
1811 }
1812 "offline" | "OFFLINE" => {
1813 eprintln!(
1814 "wire init: proceeding offline. \
1815 Run `wire bind-relay <url>` before pairing."
1816 );
1817 }
1823 url if url.starts_with("http://") || url.starts_with("https://") => {
1824 eprintln!("wire init: binding to {url}");
1825 resolved_relay = Some(url.to_string());
1826 }
1827 other => {
1828 bail!(
1829 "wire init: unrecognized answer `{other}` — \
1830 expected Y/n/offline/<url>. Re-run with --relay or --offline."
1831 );
1832 }
1833 }
1834 } else {
1835 bail!(
1836 "wire init: no relay specified and no local relay reachable at \
1837 http://127.0.0.1:8771.\n\
1838 Pick one (or just run `wire up`):\n\
1839 • `wire service install --local-relay` — start the local relay, then re-run\n\
1840 • `wire up @wireup.net` — bind to public federation in one command\n\
1841 • `wire init --offline` — generate keypair only \
1842 (peers cannot reach you until you `wire bind-relay <url>` later)"
1843 );
1844 }
1845 }
1846 }
1847 let relay = resolved_relay.as_deref();
1848
1849 config::ensure_dirs()?;
1850 let (sk_seed, pk_bytes) = generate_keypair();
1851 config::write_private_key(&sk_seed)?;
1852
1853 let seed = handle.unwrap_or("agent");
1871 let synth_did = crate::agent_card::did_for_with_key(seed, &pk_bytes);
1872 let character = crate::character::Character::from_did(&synth_did);
1873 let canonical_handle: &str = &character.nickname;
1874 if let Some(typed) = handle
1875 && typed != canonical_handle
1876 {
1877 eprintln!(
1878 "wire init: one-name rule — typed `{typed}` ignored in favor of \
1879 DID-derived persona `{canonical_handle}`. Peers will reach you as `{canonical_handle}`."
1880 );
1881 }
1882
1883 let card = build_agent_card(canonical_handle, &pk_bytes, name, None, None);
1884 let signed = sign_agent_card(&card, &sk_seed);
1885 config::write_agent_card(&signed)?;
1886
1887 let mut trust = empty_trust();
1888 add_self_to_trust(&mut trust, canonical_handle, &pk_bytes);
1889 config::write_trust(&trust)?;
1890
1891 let fp = fingerprint(&pk_bytes);
1892 let key_id = make_key_id(canonical_handle, &pk_bytes);
1893 let handle = canonical_handle;
1896
1897 let mut relay_info: Option<(String, String)> = None;
1899 if let Some(url) = relay {
1900 let normalized = url.trim_end_matches('/');
1901 let client = crate::relay_client::RelayClient::new(normalized);
1902 client.check_healthz()?;
1903 let alloc = client.allocate_slot(Some(handle))?;
1904 let mut state = config::read_relay_state()?;
1905 state["self"] = json!({
1906 "relay_url": normalized,
1907 "slot_id": alloc.slot_id.clone(),
1908 "slot_token": alloc.slot_token,
1909 });
1910 config::write_relay_state(&state)?;
1911 relay_info = Some((normalized.to_string(), alloc.slot_id));
1912 }
1913
1914 let did_str = crate::agent_card::did_for_with_key(handle, &pk_bytes);
1915 if as_json {
1916 let mut out = json!({
1917 "did": did_str.clone(),
1918 "fingerprint": fp,
1919 "key_id": key_id,
1920 "config_dir": config::config_dir()?.to_string_lossy(),
1921 });
1922 if let Some((url, slot_id)) = &relay_info {
1923 out["relay_url"] = json!(url);
1924 out["slot_id"] = json!(slot_id);
1925 }
1926 println!("{}", serde_json::to_string(&out)?);
1927 } else {
1928 println!("generated {did_str} (ed25519:{key_id})");
1929 println!(
1930 "config written to {}",
1931 config::config_dir()?.to_string_lossy()
1932 );
1933 if let Some((url, slot_id)) = &relay_info {
1934 println!("bound to relay {url} (slot {slot_id})");
1935 println!();
1936 println!(
1937 "next step: `wire pair-host --relay {url}` to print a code phrase for a peer."
1938 );
1939 } else {
1940 println!();
1941 println!(
1942 "next step: `wire pair-host --relay <url>` to bind a relay + open a pair-slot."
1943 );
1944 }
1945 }
1946 Ok(())
1947}
1948
1949fn cmd_status(as_json: bool) -> Result<()> {
1952 let initialized = config::is_initialized()?;
1953
1954 let mut summary = json!({
1955 "initialized": initialized,
1956 });
1957
1958 if initialized {
1959 let card = config::read_agent_card()?;
1960 let did = card
1961 .get("did")
1962 .and_then(Value::as_str)
1963 .unwrap_or("")
1964 .to_string();
1965 let handle = card
1969 .get("handle")
1970 .and_then(Value::as_str)
1971 .map(str::to_string)
1972 .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
1973 let pk_b64 = card
1974 .get("verify_keys")
1975 .and_then(Value::as_object)
1976 .and_then(|m| m.values().next())
1977 .and_then(|v| v.get("key"))
1978 .and_then(Value::as_str)
1979 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
1980 let pk_bytes = crate::signing::b64decode(pk_b64)?;
1981 summary["did"] = json!(did);
1982 summary["handle"] = json!(handle);
1983 summary["fingerprint"] = json!(fingerprint(&pk_bytes));
1984 summary["capabilities"] = card
1985 .get("capabilities")
1986 .cloned()
1987 .unwrap_or_else(|| json!([]));
1988
1989 let trust = config::read_trust()?;
1990 let relay_state_for_tier =
1991 config::read_relay_state().unwrap_or_else(|_| json!({"peers": {}}));
1992 let mut peers = Vec::new();
1993 if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
1994 for (peer_handle, _agent) in agents {
1995 if peer_handle == &handle {
1996 continue; }
1998 peers.push(json!({
2003 "handle": peer_handle,
2004 "tier": effective_peer_tier(&trust, &relay_state_for_tier, peer_handle),
2005 }));
2006 }
2007 }
2008 summary["peers"] = json!(peers);
2009
2010 let relay_state = config::read_relay_state()?;
2011 summary["self_relay"] = relay_state.get("self").cloned().unwrap_or(Value::Null);
2012 if !summary["self_relay"].is_null() {
2013 if let Some(obj) = summary["self_relay"].as_object_mut() {
2015 obj.remove("slot_token");
2016 }
2017 }
2018 summary["peer_slots_count"] = json!(
2019 relay_state
2020 .get("peers")
2021 .and_then(Value::as_object)
2022 .map(|m| m.len())
2023 .unwrap_or(0)
2024 );
2025
2026 let outbox = config::outbox_dir()?;
2028 let inbox = config::inbox_dir()?;
2029 summary["outbox"] = json!(scan_jsonl_dir(&outbox)?);
2030 summary["inbox"] = json!(scan_jsonl_dir(&inbox)?);
2031
2032 let snap = crate::ensure_up::daemon_liveness();
2038 let mut daemon = json!({
2039 "running": snap.pidfile_alive,
2040 "pid": snap.pidfile_pid,
2041 "all_running_pids": snap.pgrep_pids,
2042 "orphans": snap.orphan_pids,
2043 });
2044 if let crate::ensure_up::PidRecord::Json(d) = &snap.record {
2045 daemon["version"] = json!(d.version);
2046 daemon["bin_path"] = json!(d.bin_path);
2047 daemon["did"] = json!(d.did);
2048 daemon["relay_url"] = json!(d.relay_url);
2049 daemon["started_at"] = json!(d.started_at);
2050 daemon["schema"] = json!(d.schema);
2051 if d.version != env!("CARGO_PKG_VERSION") {
2052 daemon["version_mismatch"] = json!({
2053 "daemon": d.version.clone(),
2054 "cli": env!("CARGO_PKG_VERSION"),
2055 });
2056 }
2057 } else if matches!(snap.record, crate::ensure_up::PidRecord::LegacyInt(_)) {
2058 daemon["pidfile_form"] = json!("legacy-int");
2059 daemon["version_mismatch"] = json!({
2060 "daemon": "<pre-0.5.11>",
2061 "cli": env!("CARGO_PKG_VERSION"),
2062 });
2063 }
2064 summary["daemon"] = daemon;
2065
2066 let pending = crate::pending_pair::list_pending().unwrap_or_default();
2068 let mut counts: std::collections::BTreeMap<String, u32> = Default::default();
2069 for p in &pending {
2070 *counts.entry(p.status.clone()).or_default() += 1;
2071 }
2072 let pending_inbound =
2074 crate::pending_inbound_pair::list_pending_inbound().unwrap_or_default();
2075 let inbound_handles: Vec<&str> = pending_inbound
2076 .iter()
2077 .map(|p| p.peer_handle.as_str())
2078 .collect();
2079 summary["pending_pairs"] = json!({
2080 "total": pending.len(),
2081 "by_status": counts,
2082 "inbound_count": pending_inbound.len(),
2083 "inbound_handles": inbound_handles,
2084 });
2085 }
2086
2087 if as_json {
2088 println!("{}", serde_json::to_string(&summary)?);
2089 } else if !initialized {
2090 println!("not initialized — run `wire init <handle>` first");
2091 } else {
2092 println!("did: {}", summary["did"].as_str().unwrap_or("?"));
2093 println!(
2094 "fingerprint: {}",
2095 summary["fingerprint"].as_str().unwrap_or("?")
2096 );
2097 println!("capabilities: {}", summary["capabilities"]);
2098 if !summary["self_relay"].is_null() {
2099 println!(
2100 "self relay: {} (slot {})",
2101 summary["self_relay"]["relay_url"].as_str().unwrap_or("?"),
2102 summary["self_relay"]["slot_id"].as_str().unwrap_or("?")
2103 );
2104 } else {
2105 println!("self relay: (not bound — run `wire pair-host --relay <url>` to bind)");
2106 }
2107 println!(
2108 "peers: {}",
2109 summary["peers"].as_array().map(|a| a.len()).unwrap_or(0)
2110 );
2111 for p in summary["peers"].as_array().unwrap_or(&Vec::new()) {
2112 println!(
2113 " - {:<20} tier={}",
2114 p["handle"].as_str().unwrap_or(""),
2115 p["tier"].as_str().unwrap_or("?")
2116 );
2117 }
2118 println!(
2119 "outbox: {} file(s), {} event(s) queued",
2120 summary["outbox"]["files"].as_u64().unwrap_or(0),
2121 summary["outbox"]["events"].as_u64().unwrap_or(0)
2122 );
2123 println!(
2124 "inbox: {} file(s), {} event(s) received",
2125 summary["inbox"]["files"].as_u64().unwrap_or(0),
2126 summary["inbox"]["events"].as_u64().unwrap_or(0)
2127 );
2128 let daemon_running = summary["daemon"]["running"].as_bool().unwrap_or(false);
2129 let daemon_pid = summary["daemon"]["pid"]
2130 .as_u64()
2131 .map(|p| p.to_string())
2132 .unwrap_or_else(|| "—".to_string());
2133 let daemon_version = summary["daemon"]["version"].as_str().unwrap_or("");
2134 let version_suffix = if !daemon_version.is_empty() {
2135 format!(" v{daemon_version}")
2136 } else {
2137 String::new()
2138 };
2139 println!(
2140 "daemon: {} (pid {}{})",
2141 if daemon_running { "running" } else { "DOWN" },
2142 daemon_pid,
2143 version_suffix,
2144 );
2145 if let Some(mm) = summary["daemon"].get("version_mismatch") {
2147 println!(
2148 " !! version mismatch: daemon={} CLI={}. \
2149 run `wire upgrade` to swap atomically.",
2150 mm["daemon"].as_str().unwrap_or("?"),
2151 mm["cli"].as_str().unwrap_or("?"),
2152 );
2153 }
2154 if let Some(orphans) = summary["daemon"]["orphans"].as_array()
2155 && !orphans.is_empty()
2156 {
2157 let pids: Vec<String> = orphans
2158 .iter()
2159 .filter_map(|v| v.as_u64().map(|p| p.to_string()))
2160 .collect();
2161 println!(
2162 " !! orphan daemon process(es): pids {}. \
2163 pgrep saw them but pidfile didn't — likely stale process from \
2164 prior install. Multiple daemons race the relay cursor.",
2165 pids.join(", ")
2166 );
2167 }
2168 let pending_total = summary["pending_pairs"]["total"].as_u64().unwrap_or(0);
2169 let inbound_count = summary["pending_pairs"]["inbound_count"]
2170 .as_u64()
2171 .unwrap_or(0);
2172 if pending_total > 0 {
2173 print!("pending pairs: {pending_total}");
2174 if let Some(obj) = summary["pending_pairs"]["by_status"].as_object() {
2175 let parts: Vec<String> = obj
2176 .iter()
2177 .map(|(k, v)| format!("{}={}", k, v.as_u64().unwrap_or(0)))
2178 .collect();
2179 if !parts.is_empty() {
2180 print!(" ({})", parts.join(", "));
2181 }
2182 }
2183 println!();
2184 } else if inbound_count == 0 {
2185 println!("pending pairs: none");
2186 }
2187 if inbound_count > 0 {
2191 let handles: Vec<String> = summary["pending_pairs"]["inbound_handles"]
2192 .as_array()
2193 .map(|a| {
2194 a.iter()
2195 .filter_map(|v| v.as_str().map(str::to_string))
2196 .collect()
2197 })
2198 .unwrap_or_default();
2199 println!(
2200 "inbound pair requests ({inbound_count}): {} — `wire pair-list` to inspect, `wire pair-accept <peer>` to accept, `wire pair-reject <peer>` to refuse",
2201 handles.join(", "),
2202 );
2203 }
2204 }
2205 Ok(())
2206}
2207
2208fn scan_jsonl_dir(dir: &std::path::Path) -> Result<Value> {
2209 if !dir.exists() {
2210 return Ok(json!({"files": 0, "events": 0}));
2211 }
2212 let mut files = 0usize;
2213 let mut events = 0usize;
2214 for entry in std::fs::read_dir(dir)? {
2215 let path = entry?.path();
2216 if path.extension().map(|x| x == "jsonl").unwrap_or(false) {
2217 files += 1;
2218 if let Ok(body) = std::fs::read_to_string(&path) {
2219 events += body.lines().filter(|l| !l.trim().is_empty()).count();
2220 }
2221 }
2222 }
2223 Ok(json!({"files": files, "events": events}))
2224}
2225
2226fn responder_status_allowed(status: &str) -> bool {
2229 matches!(
2230 status,
2231 "online" | "offline" | "oauth_locked" | "rate_limited" | "degraded"
2232 )
2233}
2234
2235fn relay_slot_for(peer: Option<&str>) -> Result<(String, String, String, String)> {
2236 let state = config::read_relay_state()?;
2237 let (label, slot_info) = match peer {
2238 Some(peer) => (
2239 peer.to_string(),
2240 state
2241 .get("peers")
2242 .and_then(|p| p.get(peer))
2243 .ok_or_else(|| {
2244 anyhow!(
2245 "unknown peer {peer:?} in relay state — pair with them first:\n \
2246 wire add {peer}@wireup.net (or {peer}@<their-relay>)\n\
2247 (`wire peers` lists who you've already paired with.)"
2248 )
2249 })?,
2250 ),
2251 None => (
2252 "self".to_string(),
2253 state.get("self").filter(|v| !v.is_null()).ok_or_else(|| {
2254 anyhow!("self slot not bound — run `wire bind-relay <url>` first")
2255 })?,
2256 ),
2257 };
2258 let relay_url = slot_info["relay_url"]
2259 .as_str()
2260 .ok_or_else(|| anyhow!("{label} relay_url missing"))?
2261 .to_string();
2262 let slot_id = slot_info["slot_id"]
2263 .as_str()
2264 .ok_or_else(|| anyhow!("{label} slot_id missing"))?
2265 .to_string();
2266 let slot_token = slot_info["slot_token"]
2267 .as_str()
2268 .ok_or_else(|| anyhow!("{label} slot_token missing"))?
2269 .to_string();
2270 Ok((label, relay_url, slot_id, slot_token))
2271}
2272
2273fn cmd_responder_set(status: &str, reason: Option<&str>, as_json: bool) -> Result<()> {
2274 if !responder_status_allowed(status) {
2275 bail!("status must be one of: online, offline, oauth_locked, rate_limited, degraded");
2276 }
2277 let (_label, relay_url, slot_id, slot_token) = relay_slot_for(None)?;
2278 let now = time::OffsetDateTime::now_utc()
2279 .format(&time::format_description::well_known::Rfc3339)
2280 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
2281 let mut record = json!({
2282 "status": status,
2283 "set_at": now,
2284 });
2285 if let Some(reason) = reason {
2286 record["reason"] = json!(reason);
2287 }
2288 if status == "online" {
2289 record["last_success_at"] = json!(now);
2290 }
2291 let client = crate::relay_client::RelayClient::new(&relay_url);
2292 let saved = client.responder_health_set(&slot_id, &slot_token, &record)?;
2293 if as_json {
2294 println!("{}", serde_json::to_string(&saved)?);
2295 } else {
2296 let reason = saved
2297 .get("reason")
2298 .and_then(Value::as_str)
2299 .map(|r| format!(" — {r}"))
2300 .unwrap_or_default();
2301 println!(
2302 "responder {}{}",
2303 saved
2304 .get("status")
2305 .and_then(Value::as_str)
2306 .unwrap_or(status),
2307 reason
2308 );
2309 }
2310 Ok(())
2311}
2312
2313fn cmd_responder_get(peer: Option<&str>, as_json: bool) -> Result<()> {
2314 let (label, relay_url, slot_id, slot_token) = relay_slot_for(peer)?;
2315 let client = crate::relay_client::RelayClient::new(&relay_url);
2316 let health = client.responder_health_get(&slot_id, &slot_token)?;
2317 if as_json {
2318 println!(
2319 "{}",
2320 serde_json::to_string(&json!({
2321 "target": label,
2322 "responder_health": health,
2323 }))?
2324 );
2325 } else if health.is_null() {
2326 println!("{label}: responder health not reported");
2327 } else {
2328 let status = health
2329 .get("status")
2330 .and_then(Value::as_str)
2331 .unwrap_or("unknown");
2332 let reason = health
2333 .get("reason")
2334 .and_then(Value::as_str)
2335 .map(|r| format!(" — {r}"))
2336 .unwrap_or_default();
2337 let last_success = health
2338 .get("last_success_at")
2339 .and_then(Value::as_str)
2340 .map(|t| format!(" (last_success: {t})"))
2341 .unwrap_or_default();
2342 println!("{label}: {status}{reason}{last_success}");
2343 }
2344 Ok(())
2345}
2346
2347fn cmd_status_peer(peer: &str, as_json: bool) -> Result<()> {
2348 let (_label, relay_url, slot_id, slot_token) = relay_slot_for(Some(peer))?;
2349 let client = crate::relay_client::RelayClient::new(&relay_url);
2350
2351 let started = std::time::Instant::now();
2352 let transport_ok = client.healthz().unwrap_or(false);
2353 let latency_ms = started.elapsed().as_millis() as u64;
2354
2355 let (event_count, last_pull_at_unix) = client.slot_state(&slot_id, &slot_token)?;
2356 let now = std::time::SystemTime::now()
2357 .duration_since(std::time::UNIX_EPOCH)
2358 .map(|d| d.as_secs())
2359 .unwrap_or(0);
2360 let attention = match last_pull_at_unix {
2361 Some(last) if now.saturating_sub(last) <= 300 => json!({
2362 "status": "ok",
2363 "last_pull_at_unix": last,
2364 "age_seconds": now.saturating_sub(last),
2365 "event_count": event_count,
2366 }),
2367 Some(last) => json!({
2368 "status": "stale",
2369 "last_pull_at_unix": last,
2370 "age_seconds": now.saturating_sub(last),
2371 "event_count": event_count,
2372 }),
2373 None => json!({
2374 "status": "never_pulled",
2375 "last_pull_at_unix": Value::Null,
2376 "event_count": event_count,
2377 }),
2378 };
2379
2380 let responder_health = client.responder_health_get(&slot_id, &slot_token)?;
2381 let responder = if responder_health.is_null() {
2382 json!({"status": "not_reported", "record": Value::Null})
2383 } else {
2384 json!({
2385 "status": responder_health
2386 .get("status")
2387 .and_then(Value::as_str)
2388 .unwrap_or("unknown"),
2389 "record": responder_health,
2390 })
2391 };
2392
2393 let report = json!({
2394 "peer": peer,
2395 "transport": {
2396 "status": if transport_ok { "ok" } else { "error" },
2397 "relay_url": relay_url,
2398 "latency_ms": latency_ms,
2399 },
2400 "attention": attention,
2401 "responder": responder,
2402 });
2403
2404 if as_json {
2405 println!("{}", serde_json::to_string(&report)?);
2406 } else {
2407 let transport_line = if transport_ok {
2408 format!("ok relay reachable ({latency_ms}ms)")
2409 } else {
2410 "error relay unreachable".to_string()
2411 };
2412 println!("transport {transport_line}");
2413 match report["attention"]["status"].as_str().unwrap_or("unknown") {
2414 "ok" => println!(
2415 "attention ok last pull {}s ago",
2416 report["attention"]["age_seconds"].as_u64().unwrap_or(0)
2417 ),
2418 "stale" => println!(
2419 "attention stale last pull {}m ago",
2420 report["attention"]["age_seconds"].as_u64().unwrap_or(0) / 60
2421 ),
2422 "never_pulled" => println!("attention never pulled since relay reset"),
2423 other => println!("attention {other}"),
2424 }
2425 if report["responder"]["status"] == "not_reported" {
2426 println!("auto-responder not reported");
2427 } else {
2428 let record = &report["responder"]["record"];
2429 let status = record
2430 .get("status")
2431 .and_then(Value::as_str)
2432 .unwrap_or("unknown");
2433 let reason = record
2434 .get("reason")
2435 .and_then(Value::as_str)
2436 .map(|r| format!(" — {r}"))
2437 .unwrap_or_default();
2438 println!("auto-responder {status}{reason}");
2439 }
2440 }
2441 Ok(())
2442}
2443
2444fn current_cwd_display() -> String {
2452 let cwd = match std::env::current_dir() {
2453 Ok(c) => c,
2454 Err(_) => return String::from("?"),
2455 };
2456 if let Some(home) = dirs::home_dir()
2457 && let Ok(rel) = cwd.strip_prefix(&home)
2458 {
2459 let rel_str = rel.to_string_lossy();
2461 if rel_str.is_empty() {
2462 return String::from("~");
2463 }
2464 return format!("~/{}", rel_str);
2465 }
2466 cwd.to_string_lossy().into_owned()
2467}
2468
2469fn cmd_whoami(as_json: bool, short: bool, colored: bool) -> Result<()> {
2470 if !config::is_initialized()? {
2471 bail!("not initialized — run `wire init <handle>` first");
2472 }
2473 let card = config::read_agent_card()?;
2474 let did = card
2475 .get("did")
2476 .and_then(Value::as_str)
2477 .unwrap_or("")
2478 .to_string();
2479 let handle = card
2480 .get("handle")
2481 .and_then(Value::as_str)
2482 .map(str::to_string)
2483 .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
2484 let character = crate::character::Character::from_did(&did);
2488
2489 let cwd_display = current_cwd_display();
2495
2496 if short {
2499 println!("{} · {}", character.short(), cwd_display);
2500 return Ok(());
2501 }
2502 if colored {
2503 println!("{} \x1b[2m·\x1b[0m {}", character.colored(), cwd_display);
2504 return Ok(());
2505 }
2506
2507 let pk_b64 = card
2508 .get("verify_keys")
2509 .and_then(Value::as_object)
2510 .and_then(|m| m.values().next())
2511 .and_then(|v| v.get("key"))
2512 .and_then(Value::as_str)
2513 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
2514 let pk_bytes = crate::signing::b64decode(pk_b64)?;
2515 let fp = fingerprint(&pk_bytes);
2516 let key_id = make_key_id(&handle, &pk_bytes);
2517 let capabilities = card
2518 .get("capabilities")
2519 .cloned()
2520 .unwrap_or_else(|| json!(["wire/v3.1"]));
2521
2522 if as_json {
2523 let has_override = false;
2527 println!(
2528 "{}",
2529 serde_json::to_string(&json!({
2530 "did": did,
2531 "handle": handle,
2532 "fingerprint": fp,
2533 "key_id": key_id,
2534 "public_key_b64": pk_b64,
2535 "capabilities": capabilities,
2536 "config_dir": config::config_dir()?.to_string_lossy(),
2537 "persona": character,
2538 "persona_override": has_override,
2539 }))?
2540 );
2541 } else {
2542 println!("{}", character.colored());
2543 println!("{did} (ed25519:{key_id})");
2544 println!("fingerprint: {fp}");
2545 println!("capabilities: {capabilities}");
2546 }
2547 Ok(())
2548}
2549
2550fn cmd_identity(cmd: IdentityCommand) -> Result<()> {
2553 match cmd {
2554 IdentityCommand::Show { json } => cmd_whoami(json, !json, false),
2561 IdentityCommand::List { json } => cmd_session_list(json),
2562 IdentityCommand::Publish {
2563 nick,
2564 relay,
2565 public_url,
2566 hidden,
2567 json,
2568 } => cmd_claim(&nick, relay.as_deref(), public_url.as_deref(), hidden, json),
2569 IdentityCommand::Destroy { name, force, json } => cmd_session_destroy(&name, force, json),
2570 IdentityCommand::Create {
2571 name,
2572 anonymous,
2573 local: _,
2574 json,
2575 } => cmd_identity_create(name.as_deref(), anonymous, json),
2576 IdentityCommand::Persist {
2577 name,
2578 as_name,
2579 json,
2580 } => cmd_identity_persist(&name, as_name.as_deref(), json),
2581 IdentityCommand::Demote { name, json } => cmd_identity_demote(&name, json),
2582 }
2583}
2584
2585fn cmd_identity_create(name: Option<&str>, anonymous: bool, as_json: bool) -> Result<()> {
2590 if anonymous {
2591 let rand_suffix = format!("{:08x}", rand::random::<u32>());
2593 let anon_name = name
2594 .map(crate::session::sanitize_name)
2595 .unwrap_or_else(|| format!("anon-{rand_suffix}"));
2596 let anon_root = std::env::temp_dir().join(format!("wire-anon-{rand_suffix}"));
2597 std::fs::create_dir_all(&anon_root)
2598 .with_context(|| format!("creating anon root {anon_root:?}"))?;
2599 let session_home = anon_root.join("sessions").join(&anon_name);
2601 std::fs::create_dir_all(&session_home)?;
2602 let status = run_wire_with_home(&session_home, &["init", &anon_name, "--offline"])?;
2603 if !status.success() {
2604 bail!("anonymous identity init failed: {status}");
2605 }
2606 let marker = anon_root.join("anon-marker.json");
2609 std::fs::write(
2610 &marker,
2611 serde_json::to_vec_pretty(&serde_json::json!({
2612 "name": anon_name,
2613 "session_home": session_home.to_string_lossy(),
2614 "created_at": time::OffsetDateTime::now_utc()
2615 .format(&time::format_description::well_known::Rfc3339)
2616 .unwrap_or_default(),
2617 "kind": "anonymous",
2618 }))?,
2619 )?;
2620 let card = serde_json::from_slice::<Value>(&std::fs::read(
2621 session_home
2622 .join("config")
2623 .join("wire")
2624 .join("agent-card.json"),
2625 )?)?;
2626 let did = card
2627 .get("did")
2628 .and_then(Value::as_str)
2629 .unwrap_or("")
2630 .to_string();
2631 if as_json {
2632 println!(
2633 "{}",
2634 serde_json::to_string(&json!({
2635 "kind": "anonymous",
2636 "name": anon_name,
2637 "did": did,
2638 "session_home": session_home.to_string_lossy(),
2639 "anon_root": anon_root.to_string_lossy(),
2640 }))?
2641 );
2642 } else {
2643 println!("created anonymous identity `{anon_name}` ({did})");
2644 println!(
2645 " session_home: {} (dies on reboot — /tmp)",
2646 session_home.display()
2647 );
2648 println!();
2649 println!("activate in this shell:");
2650 println!(" export WIRE_HOME={}", session_home.display());
2651 println!();
2652 println!("promote to persistent later with:");
2653 println!(" wire identity persist {anon_name}");
2654 }
2655 return Ok(());
2656 }
2657 let name_arg = name.map(|s| s.to_string());
2659 cmd_session_new(
2660 name_arg.as_deref(),
2661 "https://wireup.net",
2662 false,
2663 "http://127.0.0.1:8771",
2664 false,
2665 None,
2666 false,
2667 None,
2668 true, true, as_json,
2671 )
2672}
2673
2674fn cmd_identity_persist(name: &str, as_name: Option<&str>, as_json: bool) -> Result<()> {
2677 let temp = std::env::temp_dir();
2679 let mut found: Option<(std::path::PathBuf, std::path::PathBuf)> = None;
2680 for entry in std::fs::read_dir(&temp)?.flatten() {
2681 let path = entry.path();
2682 if !path
2683 .file_name()
2684 .and_then(|s| s.to_str())
2685 .map(|s| s.starts_with("wire-anon-"))
2686 .unwrap_or(false)
2687 {
2688 continue;
2689 }
2690 let marker = path.join("anon-marker.json");
2691 if let Ok(bytes) = std::fs::read(&marker)
2692 && let Ok(json) = serde_json::from_slice::<Value>(&bytes)
2693 && json.get("name").and_then(Value::as_str) == Some(name)
2694 {
2695 let session_home = json
2696 .get("session_home")
2697 .and_then(Value::as_str)
2698 .map(std::path::PathBuf::from)
2699 .ok_or_else(|| anyhow!("anon-marker {marker:?} missing session_home"))?;
2700 found = Some((path, session_home));
2701 break;
2702 }
2703 }
2704 let (anon_root, anon_session_home) = found.ok_or_else(|| {
2705 anyhow!(
2706 "no anonymous identity named `{name}` found in /tmp/wire-anon-* — \
2707 run `wire identity list` to see available identities"
2708 )
2709 })?;
2710
2711 let new_name = as_name.unwrap_or(name);
2712 let new_session_home = crate::session::session_dir(new_name)?;
2713 if new_session_home.exists() {
2714 bail!(
2715 "target session `{new_name}` already exists at {new_session_home:?} — \
2716 pick a different name with --as <new-name>"
2717 );
2718 }
2719
2720 if let Some(parent) = new_session_home.parent() {
2722 std::fs::create_dir_all(parent)?;
2723 }
2724 std::fs::rename(&anon_session_home, &new_session_home)
2725 .with_context(|| format!("rename {anon_session_home:?} → {new_session_home:?}"))?;
2726
2727 let _ = std::fs::remove_dir_all(&anon_root);
2729
2730 let cwd = std::env::current_dir().unwrap_or_else(|_| new_session_home.clone());
2733 let cwd_key = cwd.to_string_lossy().into_owned();
2734 let new_name_for_reg = new_name.to_string();
2735 if let Err(e) = crate::session::update_registry(|reg| {
2736 reg.by_cwd.insert(cwd_key, new_name_for_reg);
2737 Ok(())
2738 }) {
2739 eprintln!("wire identity persist: failed to update registry: {e:#}");
2740 }
2741
2742 if as_json {
2743 println!(
2744 "{}",
2745 serde_json::to_string(&json!({
2746 "kind": "persisted",
2747 "from_name": name,
2748 "to_name": new_name,
2749 "session_home": new_session_home.to_string_lossy(),
2750 }))?
2751 );
2752 } else {
2753 println!("persisted anonymous identity `{name}` → local session `{new_name}`");
2754 println!(
2755 " session_home: {} (survives reboot)",
2756 new_session_home.display()
2757 );
2758 println!(" registered cwd: {}", cwd.display());
2759 }
2760 Ok(())
2761}
2762
2763fn cmd_identity_demote(name: &str, as_json: bool) -> Result<()> {
2769 let sessions = crate::session::list_sessions()?;
2770 let session = sessions
2771 .iter()
2772 .find(|s| s.name == name)
2773 .ok_or_else(|| anyhow!("no session named `{name}` (run `wire identity list`)"))?;
2774 let relay_state_path = session
2775 .home_dir
2776 .join("config")
2777 .join("wire")
2778 .join("relay.json");
2779 if !relay_state_path.exists() {
2780 bail!("session `{name}` has no relay state — already demoted?");
2781 }
2782 let mut state: Value = serde_json::from_slice(&std::fs::read(&relay_state_path)?)?;
2783 let self_obj = state.get("self").cloned().unwrap_or(Value::Null);
2784 let had_fed = self_obj
2785 .get("relay_url")
2786 .and_then(Value::as_str)
2787 .map(|u| {
2788 u.starts_with("https://") || (u.starts_with("http://") && !u.contains("127.0.0.1"))
2789 })
2790 .unwrap_or(false);
2791 if !had_fed {
2792 if as_json {
2793 println!(
2794 "{}",
2795 serde_json::to_string(
2796 &json!({"name": name, "status": "no-op", "reason": "no federation slot"})
2797 )?
2798 );
2799 } else {
2800 println!("session `{name}` has no federation slot — nothing to demote");
2801 }
2802 return Ok(());
2803 }
2804 if let Some(self_mut) = state
2807 .as_object_mut()
2808 .and_then(|m| m.get_mut("self"))
2809 .and_then(|s| s.as_object_mut())
2810 {
2811 self_mut.remove("relay_url");
2812 self_mut.remove("slot_id");
2813 self_mut.remove("slot_token");
2814 if let Some(eps) = self_mut.get_mut("endpoints").and_then(|e| e.as_array_mut()) {
2815 eps.retain(|ep| ep.get("scope").and_then(Value::as_str) != Some("federation"));
2816 }
2817 }
2818 std::fs::write(&relay_state_path, serde_json::to_vec_pretty(&state)?)?;
2819
2820 if as_json {
2821 println!(
2822 "{}",
2823 serde_json::to_string(
2824 &json!({"name": name, "status": "demoted", "from": "federation", "to": "local"})
2825 )?
2826 );
2827 } else {
2828 println!("demoted `{name}` from federation → local");
2829 println!(" relay slot binding removed; keypair + agent-card retained");
2830 println!(" re-publish with `wire identity publish <nick>`");
2831 }
2832 Ok(())
2833}
2834
2835fn effective_peer_tier(trust: &Value, relay_state: &Value, handle: &str) -> String {
2836 let raw = crate::trust::get_tier(trust, handle);
2837 if raw != "VERIFIED" {
2838 return raw.to_string();
2839 }
2840 let token = relay_state
2841 .get("peers")
2842 .and_then(|p| p.get(handle))
2843 .and_then(|p| p.get("slot_token"))
2844 .and_then(Value::as_str)
2845 .unwrap_or("");
2846 if token.is_empty() {
2847 "PENDING_ACK".to_string()
2848 } else {
2849 raw.to_string()
2850 }
2851}
2852
2853fn cmd_peers(as_json: bool) -> Result<()> {
2854 let trust = config::read_trust()?;
2855 let agents = trust
2856 .get("agents")
2857 .and_then(Value::as_object)
2858 .cloned()
2859 .unwrap_or_default();
2860 let relay_state = config::read_relay_state().unwrap_or_else(|_| json!({"peers": {}}));
2861
2862 let mut self_did: Option<String> = None;
2863 if let Ok(card) = config::read_agent_card() {
2864 self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
2865 }
2866
2867 let mut peers = Vec::new();
2868 for (handle, agent) in agents.iter() {
2869 let did = agent
2870 .get("did")
2871 .and_then(Value::as_str)
2872 .unwrap_or("")
2873 .to_string();
2874 if Some(did.as_str()) == self_did.as_deref() {
2875 continue; }
2877 let tier = effective_peer_tier(&trust, &relay_state, handle);
2878 let capabilities = agent
2879 .get("card")
2880 .and_then(|c| c.get("capabilities"))
2881 .cloned()
2882 .unwrap_or_else(|| json!([]));
2883 let character = if did.is_empty() {
2888 None
2889 } else {
2890 let card_obj = agent.get("card");
2891 Some(match card_obj {
2892 Some(card) => crate::character::Character::from_card(card),
2893 None => crate::character::Character::from_did(&did),
2894 })
2895 };
2896 peers.push(json!({
2897 "handle": handle,
2898 "did": did,
2899 "tier": tier,
2900 "capabilities": capabilities,
2901 "persona": character,
2902 }));
2903 }
2904
2905 if as_json {
2906 println!("{}", serde_json::to_string(&peers)?);
2907 } else if peers.is_empty() {
2908 println!("no peers pinned (run `wire join <code>` to pair)");
2909 } else {
2910 for p in &peers {
2916 let char_json = &p["persona"];
2917 let (colored_char, plain_len): (String, usize) = match char_json {
2918 serde_json::Value::Null => ("?".to_string(), 1),
2919 v => match serde_json::from_value::<crate::character::Character>(v.clone()) {
2920 Ok(c) => {
2921 let plain = c.short().chars().count() + 1; (c.colored(), plain)
2923 }
2924 Err(_) => ("?".to_string(), 1),
2925 },
2926 };
2927 let pad = 22usize.saturating_sub(plain_len);
2928 println!(
2929 "{}{} {:<20} {:<10} {}",
2930 colored_char,
2931 " ".repeat(pad),
2932 p["handle"].as_str().unwrap_or(""),
2933 p["tier"].as_str().unwrap_or(""),
2934 p["did"].as_str().unwrap_or(""),
2935 );
2936 }
2937 }
2938 Ok(())
2939}
2940
2941fn maybe_warn_peer_attentiveness(peer: &str) {
2951 let state = match config::read_relay_state() {
2952 Ok(s) => s,
2953 Err(_) => return,
2954 };
2955 let p = state.get("peers").and_then(|p| p.get(peer));
2956 let slot_id = match p.and_then(|p| p.get("slot_id")).and_then(Value::as_str) {
2957 Some(s) if !s.is_empty() => s,
2958 _ => return,
2959 };
2960 let slot_token = match p.and_then(|p| p.get("slot_token")).and_then(Value::as_str) {
2961 Some(s) if !s.is_empty() => s,
2962 _ => return,
2963 };
2964 let relay_url = match p.and_then(|p| p.get("relay_url")).and_then(Value::as_str) {
2965 Some(s) if !s.is_empty() => s.to_string(),
2966 _ => match state
2967 .get("self")
2968 .and_then(|s| s.get("relay_url"))
2969 .and_then(Value::as_str)
2970 {
2971 Some(s) if !s.is_empty() => s.to_string(),
2972 _ => return,
2973 },
2974 };
2975 let client = crate::relay_client::RelayClient::new(&relay_url);
2976 let (_count, last_pull) = match client.slot_state(slot_id, slot_token) {
2977 Ok(t) => t,
2978 Err(_) => return,
2979 };
2980 let now = std::time::SystemTime::now()
2981 .duration_since(std::time::UNIX_EPOCH)
2982 .map(|d| d.as_secs())
2983 .unwrap_or(0);
2984 match last_pull {
2985 None => {
2986 eprintln!(
2987 "phyllis: {peer}'s line is silent — relay sees no pulls yet. message will queue, but they may not be listening."
2988 );
2989 }
2990 Some(t) if now.saturating_sub(t) > 300 => {
2991 let mins = now.saturating_sub(t) / 60;
2992 eprintln!(
2993 "phyllis: {peer} hasn't picked up in {mins}m — message will queue, but they may be away."
2994 );
2995 }
2996 _ => {}
2997 }
2998}
2999
3000pub(crate) fn parse_deadline_until(input: &str) -> Result<String> {
3001 let trimmed = input.trim();
3002 if time::OffsetDateTime::parse(trimmed, &time::format_description::well_known::Rfc3339).is_ok()
3003 {
3004 return Ok(trimmed.to_string());
3005 }
3006 let (amount, unit) = trimmed.split_at(trimmed.len().saturating_sub(1));
3007 let n: i64 = amount
3008 .parse()
3009 .with_context(|| format!("deadline must be `30m`, `2h`, `1d`, or RFC3339: {input:?}"))?;
3010 if n <= 0 {
3011 bail!("deadline duration must be positive: {input:?}");
3012 }
3013 let duration = match unit {
3014 "m" => time::Duration::minutes(n),
3015 "h" => time::Duration::hours(n),
3016 "d" => time::Duration::days(n),
3017 _ => bail!("deadline must end in m, h, d, or be RFC3339: {input:?}"),
3018 };
3019 Ok((time::OffsetDateTime::now_utc() + duration)
3020 .format(&time::format_description::well_known::Rfc3339)
3021 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()))
3022}
3023
3024fn cmd_send(
3025 peer: &str,
3026 kind: &str,
3027 body_arg: &str,
3028 deadline: Option<&str>,
3029 no_auto_pair: bool,
3033 as_json: bool,
3034) -> Result<()> {
3035 if !config::is_initialized()? {
3036 bail!("not initialized — run `wire init <handle>` first");
3037 }
3038 let peer_in = crate::agent_card::bare_handle(peer).to_string();
3039 let peer = match resolve_peer_handle(&peer_in) {
3046 Ok(Some(resolved)) if resolved != peer_in => {
3047 eprintln!("wire send: resolved nickname `{peer_in}` → peer `{resolved}`");
3048 resolved
3049 }
3050 Ok(Some(canonical)) => canonical, Ok(None) => peer_in, Err(ResolveError::Ambiguous(candidates)) => bail!(
3053 "nickname `{peer_in}` is ambiguous — matches {} pinned peers: {}. \
3054 Disambiguate by passing the peer handle (one of those listed) instead of the nickname.",
3055 candidates.len(),
3056 candidates.join(", ")
3057 ),
3058 Err(ResolveError::NotFound) => peer_in, };
3060
3061 let peer_is_pinned = config::read_relay_state()
3068 .ok()
3069 .and_then(|s| s.get("peers").and_then(Value::as_object).cloned())
3070 .map(|peers| peers.contains_key(&peer))
3071 .unwrap_or(false);
3072 if !peer_is_pinned && let Some(sister_name) = crate::session::resolve_local_sister(&peer) {
3073 if no_auto_pair {
3074 bail!(
3075 "wire send: `{peer}` resolves to local sister `{sister_name}` but is not pinned, \
3076 and --no-auto-pair was passed. Run `wire dial {peer}` first, \
3077 then re-run send."
3078 );
3079 }
3080 eprintln!(
3081 "wire send: `{peer}` not pinned yet — auto-pairing via local-sister `{sister_name}` first. \
3082 Pass --no-auto-pair to refuse implicit dialing."
3083 );
3084 cmd_add_local_sister(&sister_name, true).map_err(|e| {
3085 anyhow!("wire send: auto-pair to local sister `{sister_name}` failed: {e:#}")
3086 })?;
3087 }
3088
3089 let peer = peer.as_str();
3090 let sk_seed = config::read_private_key()?;
3091 let card = config::read_agent_card()?;
3092 let did = card.get("did").and_then(Value::as_str).unwrap_or("");
3093 let handle = crate::agent_card::display_handle_from_did(did).to_string();
3094 let pk_b64 = card
3095 .get("verify_keys")
3096 .and_then(Value::as_object)
3097 .and_then(|m| m.values().next())
3098 .and_then(|v| v.get("key"))
3099 .and_then(Value::as_str)
3100 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
3101 let pk_bytes = crate::signing::b64decode(pk_b64)?;
3102
3103 let body_value: Value = if body_arg == "-" {
3108 use std::io::Read;
3109 let mut raw = String::new();
3110 std::io::stdin()
3111 .read_to_string(&mut raw)
3112 .with_context(|| "reading body from stdin")?;
3113 serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
3116 } else if let Some(path) = body_arg.strip_prefix('@') {
3117 let raw =
3118 std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
3119 serde_json::from_str(&raw).unwrap_or(Value::String(raw))
3120 } else {
3121 Value::String(body_arg.to_string())
3122 };
3123
3124 let kind_id = parse_kind(kind)?;
3125
3126 let now = time::OffsetDateTime::now_utc()
3127 .format(&time::format_description::well_known::Rfc3339)
3128 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
3129
3130 let mut event = json!({
3131 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
3132 "timestamp": now,
3133 "from": did,
3134 "to": format!("did:wire:{peer}"),
3135 "type": kind,
3136 "kind": kind_id,
3137 "body": body_value,
3138 });
3139 if let Some(deadline) = deadline {
3140 event["time_sensitive_until"] = json!(parse_deadline_until(deadline)?);
3141 }
3142 let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)?;
3143 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
3144
3145 maybe_warn_peer_attentiveness(peer);
3150
3151 let line = serde_json::to_vec(&signed)?;
3156 let outbox = config::append_outbox_record(peer, &line)?;
3157
3158 if as_json {
3159 println!(
3160 "{}",
3161 serde_json::to_string(&json!({
3162 "event_id": event_id,
3163 "status": "queued",
3164 "peer": peer,
3165 "outbox": outbox.to_string_lossy(),
3166 }))?
3167 );
3168 } else {
3169 println!(
3170 "queued event {event_id} → {peer} (outbox: {})",
3171 outbox.display()
3172 );
3173 }
3174 Ok(())
3175}
3176
3177fn parse_kind(s: &str) -> Result<u32> {
3178 if let Ok(n) = s.parse::<u32>() {
3179 return Ok(n);
3180 }
3181 for (id, name) in crate::signing::kinds() {
3182 if *name == s {
3183 return Ok(*id);
3184 }
3185 }
3186 Ok(1)
3188}
3189
3190fn cmd_here(as_json: bool) -> Result<()> {
3196 let initialized = config::is_initialized().unwrap_or(false);
3197
3198 let (self_did, self_handle, self_character) = if initialized {
3200 let card = config::read_agent_card().ok();
3201 let did = card
3202 .as_ref()
3203 .and_then(|c| c.get("did").and_then(Value::as_str))
3204 .unwrap_or("")
3205 .to_string();
3206 let handle = if did.is_empty() {
3207 String::new()
3208 } else {
3209 crate::agent_card::display_handle_from_did(&did).to_string()
3210 };
3211 let character = if did.is_empty() {
3212 None
3213 } else {
3214 Some(crate::character::Character::from_did(&did))
3216 };
3217 (did, handle, character)
3218 } else {
3219 (String::new(), String::new(), None)
3220 };
3221
3222 let cwd = std::env::current_dir()
3223 .map(|p| p.to_string_lossy().into_owned())
3224 .unwrap_or_default();
3225 let wire_home = std::env::var("WIRE_HOME").unwrap_or_default();
3226
3227 let mut sisters: Vec<Value> = Vec::new();
3229 if let Ok(listing) = crate::session::list_local_sessions() {
3230 for group in listing.local.values() {
3231 for s in group {
3232 if s.handle.as_deref() == Some(self_handle.as_str()) {
3233 continue; }
3235 let ch = s.did.as_deref().map(crate::character::Character::from_did);
3236 sisters.push(json!({
3237 "session": s.name,
3238 "handle": s.handle,
3239 "persona": ch,
3240 }));
3241 }
3242 }
3243 }
3244
3245 let mut peers: Vec<Value> = Vec::new();
3247 if initialized
3248 && let Ok(trust) = config::read_trust()
3249 && let Some(agents) = trust.get("agents").and_then(Value::as_object)
3250 {
3251 for (handle, agent) in agents {
3252 if handle == &self_handle {
3253 continue; }
3255 let did = agent.get("did").and_then(Value::as_str).unwrap_or("");
3256 let ch = if did.is_empty() {
3257 None
3258 } else {
3259 Some(crate::character::Character::from_did(did))
3260 };
3261 peers.push(json!({
3262 "handle": handle,
3263 "did": did,
3264 "tier": agent.get("tier").and_then(Value::as_str).unwrap_or("UNKNOWN"),
3265 "persona": ch,
3266 }));
3267 }
3268 }
3269
3270 if as_json {
3271 println!(
3272 "{}",
3273 serde_json::to_string(&json!({
3274 "self": {
3275 "handle": self_handle,
3276 "did": self_did,
3277 "persona": self_character,
3278 "cwd": cwd,
3279 "wire_home": wire_home,
3280 },
3281 "sister_sessions": sisters,
3282 "pinned_peers": peers,
3283 }))?
3284 );
3285 return Ok(());
3286 }
3287
3288 if !initialized {
3290 println!("not initialized — run `wire init <handle>` to bootstrap.");
3291 return Ok(());
3292 }
3293 let glyph = self_character
3294 .as_ref()
3295 .map(crate::character::emoji_with_fallback)
3296 .unwrap_or_else(|| "?".to_string());
3297 let nick = self_character
3298 .as_ref()
3299 .map(|c| c.nickname.clone())
3300 .unwrap_or_default();
3301 println!("you are {glyph} {nick} ({self_handle})");
3302 if !cwd.is_empty() {
3303 println!(" cwd: {cwd}");
3304 }
3305 let render_glyph = |character: &Value| -> String {
3310 let emoji = character
3311 .get("emoji")
3312 .and_then(Value::as_str)
3313 .unwrap_or("?");
3314 let nickname = character
3315 .get("nickname")
3316 .and_then(Value::as_str)
3317 .unwrap_or("?");
3318 if crate::character::terminal_supports_emoji() {
3319 return emoji.to_string();
3320 }
3321 let synth = crate::character::Character {
3324 nickname: nickname.to_string(),
3325 emoji: emoji.to_string(),
3326 palette: crate::character::Palette {
3327 primary_hex: String::new(),
3328 accent_hex: String::new(),
3329 ansi256_primary: 0,
3330 ansi256_accent: 0,
3331 },
3332 };
3333 crate::character::emoji_with_fallback(&synth)
3334 };
3335 if !sisters.is_empty() {
3336 println!();
3337 println!("sister sessions on this machine:");
3338 for s in &sisters {
3339 let session = s["session"].as_str().unwrap_or("?");
3340 let ch_nick = s["persona"]["nickname"].as_str().unwrap_or("?");
3341 let glyph = render_glyph(&s["persona"]);
3342 println!(" {glyph} {ch_nick} ({session})");
3343 }
3344 }
3345 if !peers.is_empty() {
3346 println!();
3347 println!("pinned peers:");
3348 for p in &peers {
3349 let handle = p["handle"].as_str().unwrap_or("?");
3350 let tier = p["tier"].as_str().unwrap_or("");
3351 let ch_nick = p["persona"]["nickname"].as_str().unwrap_or("?");
3352 let glyph = render_glyph(&p["persona"]);
3353 println!(" {glyph} {ch_nick} ({handle}) [{tier}]");
3354 }
3355 }
3356 if sisters.is_empty() && peers.is_empty() {
3357 println!();
3358 println!(
3359 "no neighbors yet — `wire session new` to add a sister, or `wire dial <peer>` to reach out."
3360 );
3361 }
3362 Ok(())
3363}
3364
3365fn cmd_dial(name: &str, message: Option<&str>, as_json: bool) -> Result<()> {
3377 if name.contains('@') {
3378 cmd_add(name, None, false, true)
3384 .map_err(|e| anyhow!("wire dial: federation pair to `{name}` failed: {e:#}"))?;
3385 if let Some(msg) = message {
3386 let bare = name.split('@').next().unwrap_or(name);
3388 cmd_send(bare, "claim", msg, None, false, as_json)?;
3389 }
3390 return Ok(());
3391 }
3392
3393 let resolution = match resolve_name_to_target(name) {
3398 Ok(r) => r,
3399 Err(e) if as_json => {
3400 let pool = known_local_names();
3401 let suggestions = closest_candidates(name, &pool, 3, 3);
3402 println!(
3403 "{}",
3404 serde_json::to_string(&json!({
3405 "name_input": name,
3406 "found": false,
3407 "candidates": suggestions,
3408 "error": format!("{e:#}"),
3409 }))?
3410 );
3411 return Ok(());
3412 }
3413 Err(e) => return Err(e),
3414 };
3415 let mut steps: Vec<Value> = Vec::new();
3416
3417 match &resolution {
3418 DialTarget::PinnedPeer { handle, .. } => {
3419 steps.push(json!({
3420 "step": "resolved",
3421 "kind": "already_pinned",
3422 "handle": handle,
3423 }));
3424 }
3425 DialTarget::LocalSister { session_name, .. } => {
3426 steps.push(json!({
3427 "step": "resolved",
3428 "kind": "local_sister",
3429 "session": session_name,
3430 }));
3431 cmd_add_local_sister(session_name, true).map_err(|e| {
3437 anyhow!("dial: local-sister pair to `{session_name}` failed: {e:#}")
3438 })?;
3439 steps.push(json!({
3440 "step": "paired",
3441 "via": "local_sister",
3442 }));
3443 }
3444 }
3445
3446 let send_handle = match &resolution {
3447 DialTarget::PinnedPeer { handle, .. } => handle.clone(),
3448 DialTarget::LocalSister { handle, .. } => handle.clone(),
3449 };
3450
3451 let send_result = if let Some(msg) = message {
3452 let r = cmd_send(&send_handle, "claim", msg, None, false, true);
3453 match &r {
3454 Ok(()) => steps.push(json!({"step": "sent", "to": send_handle, "kind": "claim"})),
3455 Err(e) => steps.push(json!({"step": "send_failed", "error": format!("{e:#}")})),
3456 }
3457 Some(r)
3458 } else {
3459 None
3460 };
3461
3462 if as_json {
3463 println!(
3464 "{}",
3465 serde_json::to_string(&json!({
3466 "name_input": name,
3467 "resolved_handle": send_handle,
3468 "steps": steps,
3469 }))?
3470 );
3471 } else {
3472 println!("wire dial: resolved `{name}` → handle `{send_handle}`");
3473 for s in &steps {
3474 let step = s.get("step").and_then(Value::as_str).unwrap_or("?");
3475 println!(" - {step}");
3476 }
3477 if message.is_some() {
3478 println!(" (use `wire tail {send_handle}` to read replies)");
3479 }
3480 }
3481 if let Some(Err(e)) = send_result {
3482 return Err(e);
3483 }
3484 Ok(())
3485}
3486
3487fn cmd_whois_local(name: &str, as_json: bool) -> Result<()> {
3493 let resolution = match resolve_name_to_target(name) {
3499 Ok(r) => r,
3500 Err(e) if as_json => {
3501 let pool = known_local_names();
3502 let suggestions = closest_candidates(name, &pool, 3, 3);
3503 println!(
3504 "{}",
3505 serde_json::to_string(&json!({
3506 "name_input": name,
3507 "found": false,
3508 "candidates": suggestions,
3509 "error": format!("{e:#}"),
3510 }))?
3511 );
3512 return Ok(());
3513 }
3514 Err(e) => return Err(e),
3515 };
3516 match resolution {
3517 DialTarget::PinnedPeer {
3518 handle,
3519 did,
3520 nickname,
3521 emoji,
3522 tier,
3523 } => {
3524 if as_json {
3525 println!(
3526 "{}",
3527 serde_json::to_string(&json!({
3528 "kind": "pinned_peer",
3529 "handle": handle,
3530 "did": did,
3531 "nickname": nickname,
3532 "emoji": emoji,
3533 "tier": tier,
3534 }))?
3535 );
3536 } else {
3537 let n = nickname.as_deref().unwrap_or("(no character)");
3538 let e = emoji.as_deref().unwrap_or("?");
3539 println!("{e} {n}");
3540 println!(" handle: {handle}");
3541 println!(" did: {did}");
3542 println!(" tier: {tier}");
3543 println!(" reach: pinned peer (already in trust ring + slot pinned)");
3544 }
3545 }
3546 DialTarget::LocalSister {
3547 session_name,
3548 handle,
3549 did,
3550 nickname,
3551 emoji,
3552 } => {
3553 if as_json {
3554 println!(
3555 "{}",
3556 serde_json::to_string(&json!({
3557 "kind": "local_sister",
3558 "session_name": session_name,
3559 "handle": handle,
3560 "did": did,
3561 "nickname": nickname,
3562 "emoji": emoji,
3563 }))?
3564 );
3565 } else {
3566 let n = nickname.as_deref().unwrap_or("(no character)");
3567 let e = emoji.as_deref().unwrap_or("?");
3568 println!("{e} {n}");
3569 println!(" session: {session_name}");
3570 println!(" handle: {handle}");
3571 println!(
3572 " did: {}",
3573 did.as_deref().unwrap_or("(card unreadable)")
3574 );
3575 println!(" reach: local sister on this machine — `wire dial {n}` pairs us");
3576 }
3577 }
3578 }
3579 Ok(())
3580}
3581
3582enum DialTarget {
3583 PinnedPeer {
3584 handle: String,
3585 did: String,
3586 nickname: Option<String>,
3587 emoji: Option<String>,
3588 tier: String,
3589 },
3590 LocalSister {
3591 session_name: String,
3592 handle: String,
3593 did: Option<String>,
3594 nickname: Option<String>,
3595 emoji: Option<String>,
3596 },
3597}
3598
3599fn resolve_name_to_target(name: &str) -> Result<DialTarget> {
3603 let needle = name.trim();
3604 if needle.is_empty() {
3605 bail!("empty name");
3606 }
3607
3608 if config::is_initialized().unwrap_or(false) {
3611 let trust = config::read_trust().unwrap_or(serde_json::Value::Null);
3612 if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
3613 for (handle_key, agent) in agents {
3614 let did = agent.get("did").and_then(Value::as_str).unwrap_or("");
3615 if did.is_empty() {
3616 continue;
3617 }
3618 let handle = handle_key.clone();
3619 let character = crate::character::Character::from_did(did);
3620 let tier = agent
3621 .get("tier")
3622 .and_then(Value::as_str)
3623 .unwrap_or("UNKNOWN")
3624 .to_string();
3625 let matches = handle.eq_ignore_ascii_case(needle)
3626 || did.eq_ignore_ascii_case(needle)
3627 || character.nickname.eq_ignore_ascii_case(needle);
3628 if matches {
3629 return Ok(DialTarget::PinnedPeer {
3630 handle,
3631 did: did.to_string(),
3632 nickname: Some(character.nickname),
3633 emoji: Some(character.emoji.to_string()),
3634 tier,
3635 });
3636 }
3637 }
3638 }
3639 }
3640
3641 if let Some(session_name) = crate::session::resolve_local_sister(needle) {
3643 let sessions = crate::session::list_sessions().unwrap_or_default();
3644 let s = sessions.iter().find(|s| s.name == session_name);
3645 if let Some(s) = s {
3646 return Ok(DialTarget::LocalSister {
3647 session_name: s.name.clone(),
3648 handle: s.handle.clone().unwrap_or_else(|| s.name.clone()),
3649 did: s.did.clone(),
3650 nickname: s.character.as_ref().map(|c| c.nickname.clone()),
3651 emoji: s.character.as_ref().map(|c| c.emoji.to_string()),
3652 });
3653 }
3654 }
3655
3656 let pool = known_local_names();
3661 let suggestions = closest_candidates(name, &pool, 3, 3);
3662 if suggestions.is_empty() {
3663 bail!(
3664 "no peer matched `{name}`.\n\
3665 Tried: pinned peers (`wire peers`) + local sister sessions \
3666 (`wire session list-local`).\n\
3667 For cross-machine federation: `wire dial <handle>@<relay-domain>`."
3668 );
3669 }
3670 bail!(
3671 "no peer matched `{name}`.\n\
3672 Did you mean: {}?\n\
3673 List all: `wire peers`, `wire session list-local`.",
3674 suggestions
3675 .iter()
3676 .map(|s| format!("`{s}`"))
3677 .collect::<Vec<_>>()
3678 .join(", ")
3679 );
3680}
3681
3682fn cmd_tail(peer: Option<&str>, as_json: bool, limit: usize) -> Result<()> {
3685 let inbox = config::inbox_dir()?;
3686 if !inbox.exists() {
3687 if !as_json {
3688 eprintln!("no inbox yet — daemon hasn't run, or no events received");
3689 }
3690 return Ok(());
3691 }
3692 let trust = config::read_trust()?;
3693 let mut count = 0usize;
3694
3695 let entries: Vec<_> = std::fs::read_dir(&inbox)?
3696 .filter_map(|e| e.ok())
3697 .map(|e| e.path())
3698 .filter(|p| {
3699 p.extension().map(|x| x == "jsonl").unwrap_or(false)
3700 && match peer {
3701 Some(want) => p.file_stem().and_then(|s| s.to_str()) == Some(want),
3702 None => true,
3703 }
3704 })
3705 .collect();
3706
3707 for path in entries {
3708 let body = std::fs::read_to_string(&path)?;
3709 for line in body.lines() {
3710 let event: Value = match serde_json::from_str(line) {
3711 Ok(v) => v,
3712 Err(_) => continue,
3713 };
3714 let verified = verify_message_v31(&event, &trust).is_ok();
3715 if as_json {
3716 let mut event_with_meta = event.clone();
3717 if let Some(obj) = event_with_meta.as_object_mut() {
3718 obj.insert("verified".into(), json!(verified));
3719 }
3720 println!("{}", serde_json::to_string(&event_with_meta)?);
3721 } else {
3722 let ts = event
3723 .get("timestamp")
3724 .and_then(Value::as_str)
3725 .unwrap_or("?");
3726 let from = event.get("from").and_then(Value::as_str).unwrap_or("?");
3727 let kind = event.get("kind").and_then(Value::as_u64).unwrap_or(0);
3728 let kind_name = event.get("type").and_then(Value::as_str).unwrap_or("?");
3729 let summary = event
3730 .get("body")
3731 .map(|b| match b {
3732 Value::String(s) => s.clone(),
3733 _ => b.to_string(),
3734 })
3735 .unwrap_or_default();
3736 let mark = if verified { "✓" } else { "✗" };
3737 let deadline = event
3738 .get("time_sensitive_until")
3739 .and_then(Value::as_str)
3740 .map(|d| format!(" deadline: {d}"))
3741 .unwrap_or_default();
3742 println!("[{ts} {from} kind={kind} {kind_name}{deadline}] {summary} | sig {mark}");
3743 }
3744 count += 1;
3745 if limit > 0 && count >= limit {
3746 return Ok(());
3747 }
3748 }
3749 }
3750 Ok(())
3751}
3752
3753fn monitor_is_noise_kind(kind: &str) -> bool {
3759 matches!(kind, "pair_drop" | "pair_drop_ack" | "heartbeat")
3760}
3761
3762fn resolve_persona(peer_handle: &str) -> Option<crate::character::Character> {
3766 let trust = config::read_trust().ok()?;
3767 let agent = trust.get("agents").and_then(|a| a.get(peer_handle))?;
3768 if let Some(card) = agent.get("card") {
3769 Some(crate::character::Character::from_card(card))
3770 } else {
3771 let did = agent.get("did").and_then(Value::as_str)?;
3772 Some(crate::character::Character::from_did(did))
3773 }
3774}
3775
3776fn persona_label(peer_handle: &str) -> String {
3778 match resolve_persona(peer_handle) {
3779 Some(ch) => format!("{} {}", ch.emoji, ch.nickname),
3780 None => peer_handle.to_string(),
3781 }
3782}
3783
3784fn monitor_render(e: &crate::inbox_watch::InboxEvent, as_json: bool) -> Result<String> {
3792 if as_json {
3793 Ok(serde_json::to_string(e)?)
3794 } else {
3795 let eid_short: String = e.event_id.chars().take(12).collect();
3796 let body = e.body_preview.replace('\n', " ");
3797 let ts: String = e.timestamp.chars().take(19).collect();
3798 Ok(format!("[{ts}] {}/{} ({eid_short}) {body}", e.peer, e.kind))
3799 }
3800}
3801
3802fn cmd_monitor(
3818 peer_filter: Option<&str>,
3819 as_json: bool,
3820 include_handshake: bool,
3821 interval_ms: u64,
3822 replay: usize,
3823) -> Result<()> {
3824 let inbox_dir = config::inbox_dir()?;
3825 if !inbox_dir.exists() && !as_json {
3826 eprintln!("wire monitor: inbox dir {inbox_dir:?} missing — has the daemon ever run?");
3827 }
3828 if replay > 0 && inbox_dir.exists() {
3834 let mut all: Vec<crate::inbox_watch::InboxEvent> = Vec::new();
3835 for entry in std::fs::read_dir(&inbox_dir)?.flatten() {
3836 let path = entry.path();
3837 if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
3838 continue;
3839 }
3840 let peer = match path.file_stem().and_then(|s| s.to_str()) {
3841 Some(s) => s.to_string(),
3842 None => continue,
3843 };
3844 if let Some(filter) = peer_filter
3845 && peer != filter
3846 {
3847 continue;
3848 }
3849 let body = std::fs::read_to_string(&path).unwrap_or_default();
3850 for line in body.lines() {
3851 let line = line.trim();
3852 if line.is_empty() {
3853 continue;
3854 }
3855 let signed: Value = match serde_json::from_str(line) {
3856 Ok(v) => v,
3857 Err(_) => continue,
3858 };
3859 let ev = crate::inbox_watch::InboxEvent::from_signed(
3860 &peer, signed, true,
3861 );
3862 if !include_handshake && monitor_is_noise_kind(&ev.kind) {
3863 continue;
3864 }
3865 all.push(ev);
3866 }
3867 }
3868 all.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
3871 let start = all.len().saturating_sub(replay);
3872 for ev in &all[start..] {
3873 println!("{}", monitor_render(ev, as_json)?);
3874 }
3875 use std::io::Write;
3876 std::io::stdout().flush().ok();
3877 }
3878
3879 let mut w = crate::inbox_watch::InboxWatcher::from_head()?;
3882 let sleep_dur = std::time::Duration::from_millis(interval_ms.max(50));
3883
3884 loop {
3885 let events = match w.poll() {
3892 Ok(evs) => evs,
3893 Err(e) => {
3894 eprintln!("wire monitor: poll error (continuing to watch): {e:#}");
3895 std::thread::sleep(sleep_dur);
3896 continue;
3897 }
3898 };
3899 let mut wrote = false;
3900 for ev in events {
3901 if let Some(filter) = peer_filter
3902 && ev.peer != filter
3903 {
3904 continue;
3905 }
3906 if !include_handshake && monitor_is_noise_kind(&ev.kind) {
3907 continue;
3908 }
3909 println!("{}", monitor_render(&ev, as_json)?);
3910 wrote = true;
3911 }
3912 if wrote {
3913 use std::io::Write;
3914 std::io::stdout().flush().ok();
3915 }
3916 std::thread::sleep(sleep_dur);
3917 }
3918}
3919
3920#[cfg(test)]
3921mod tier_tests {
3922 use super::*;
3923 use serde_json::json;
3924
3925 fn trust_with(handle: &str, tier: &str) -> Value {
3926 json!({
3927 "version": 1,
3928 "agents": {
3929 handle: {
3930 "tier": tier,
3931 "did": format!("did:wire:{handle}"),
3932 "card": {"capabilities": ["wire/v3.1"]}
3933 }
3934 }
3935 })
3936 }
3937
3938 #[test]
3939 fn pending_ack_when_verified_but_no_slot_token() {
3940 let trust = trust_with("willard", "VERIFIED");
3944 let relay_state = json!({
3945 "peers": {
3946 "willard": {
3947 "relay_url": "https://relay",
3948 "slot_id": "abc",
3949 "slot_token": "",
3950 }
3951 }
3952 });
3953 assert_eq!(
3954 effective_peer_tier(&trust, &relay_state, "willard"),
3955 "PENDING_ACK"
3956 );
3957 }
3958
3959 #[test]
3960 fn verified_when_slot_token_present() {
3961 let trust = trust_with("willard", "VERIFIED");
3962 let relay_state = json!({
3963 "peers": {
3964 "willard": {
3965 "relay_url": "https://relay",
3966 "slot_id": "abc",
3967 "slot_token": "tok123",
3968 }
3969 }
3970 });
3971 assert_eq!(
3972 effective_peer_tier(&trust, &relay_state, "willard"),
3973 "VERIFIED"
3974 );
3975 }
3976
3977 #[test]
3978 fn raw_tier_passes_through_for_non_verified() {
3979 let trust = trust_with("willard", "UNTRUSTED");
3982 let relay_state = json!({
3983 "peers": {"willard": {"slot_token": ""}}
3984 });
3985 assert_eq!(
3986 effective_peer_tier(&trust, &relay_state, "willard"),
3987 "UNTRUSTED"
3988 );
3989 }
3990
3991 #[test]
3992 fn pending_ack_when_relay_state_missing_peer() {
3993 let trust = trust_with("willard", "VERIFIED");
3997 let relay_state = json!({"peers": {}});
3998 assert_eq!(
3999 effective_peer_tier(&trust, &relay_state, "willard"),
4000 "PENDING_ACK"
4001 );
4002 }
4003}
4004
4005#[cfg(test)]
4006mod monitor_tests {
4007 use super::*;
4008 use crate::inbox_watch::InboxEvent;
4009 use serde_json::Value;
4010
4011 fn ev(peer: &str, kind: &str, body: &str) -> InboxEvent {
4012 InboxEvent {
4013 peer: peer.to_string(),
4014 event_id: "abcd1234567890ef".to_string(),
4015 kind: kind.to_string(),
4016 body_preview: body.to_string(),
4017 verified: true,
4018 timestamp: "2026-05-15T23:14:07.123456Z".to_string(),
4019 raw: Value::Null,
4020 }
4021 }
4022
4023 #[test]
4024 fn monitor_filter_drops_handshake_kinds_by_default() {
4025 assert!(monitor_is_noise_kind("pair_drop"));
4030 assert!(monitor_is_noise_kind("pair_drop_ack"));
4031 assert!(monitor_is_noise_kind("heartbeat"));
4032
4033 assert!(!monitor_is_noise_kind("claim"));
4035 assert!(!monitor_is_noise_kind("decision"));
4036 assert!(!monitor_is_noise_kind("ack"));
4037 assert!(!monitor_is_noise_kind("request"));
4038 assert!(!monitor_is_noise_kind("note"));
4039 assert!(!monitor_is_noise_kind("future_kind_we_dont_know"));
4043 }
4044
4045 #[test]
4046 fn monitor_render_plain_is_one_short_line() {
4047 let e = ev("willard", "claim", "real v8 train shipped 1350 steps");
4048 let line = monitor_render(&e, false).unwrap();
4049 assert!(!line.contains('\n'), "render must be one line: {line}");
4051 assert!(line.contains("willard"));
4053 assert!(line.contains("claim"));
4054 assert!(line.contains("real v8 train"));
4055 assert!(line.contains("abcd12345678"));
4057 assert!(
4058 !line.contains("abcd1234567890ef"),
4059 "should truncate full id"
4060 );
4061 assert!(line.contains("2026-05-15T23:14:07"));
4063 }
4064
4065 #[test]
4066 fn monitor_render_strips_newlines_from_body() {
4067 let e = ev("spark", "claim", "line one\nline two\nline three");
4072 let line = monitor_render(&e, false).unwrap();
4073 assert!(!line.contains('\n'), "newlines must be stripped: {line}");
4074 assert!(line.contains("line one line two line three"));
4075 }
4076
4077 #[test]
4078 fn monitor_render_json_is_valid_jsonl() {
4079 let e = ev("spark", "claim", "hi");
4080 let line = monitor_render(&e, true).unwrap();
4081 assert!(!line.contains('\n'));
4082 let parsed: Value = serde_json::from_str(&line).expect("valid JSONL");
4083 assert_eq!(parsed["peer"], "spark");
4084 assert_eq!(parsed["kind"], "claim");
4085 assert_eq!(parsed["body_preview"], "hi");
4086 }
4087
4088 #[test]
4089 fn monitor_does_not_drop_on_verified_null() {
4090 let mut e = ev("spark", "claim", "from disk with verified=null");
4101 e.verified = false; let line = monitor_render(&e, false).unwrap();
4103 assert!(line.contains("from disk with verified=null"));
4104 assert!(!monitor_is_noise_kind("claim"));
4106 }
4107}
4108
4109fn cmd_verify(path: &str, as_json: bool) -> Result<()> {
4112 let body = if path == "-" {
4113 let mut buf = String::new();
4114 use std::io::Read;
4115 std::io::stdin().read_to_string(&mut buf)?;
4116 buf
4117 } else {
4118 std::fs::read_to_string(path).with_context(|| format!("reading {path}"))?
4119 };
4120 let event: Value = serde_json::from_str(&body)?;
4121 let trust = config::read_trust()?;
4122 match verify_message_v31(&event, &trust) {
4123 Ok(()) => {
4124 if as_json {
4125 println!("{}", serde_json::to_string(&json!({"verified": true}))?);
4126 } else {
4127 println!("verified ✓");
4128 }
4129 Ok(())
4130 }
4131 Err(e) => {
4132 let reason = e.to_string();
4133 if as_json {
4134 println!(
4135 "{}",
4136 serde_json::to_string(&json!({"verified": false, "reason": reason}))?
4137 );
4138 } else {
4139 eprintln!("FAILED: {reason}");
4140 }
4141 std::process::exit(1);
4142 }
4143 }
4144}
4145
4146fn cmd_mcp() -> Result<()> {
4149 crate::mcp::run()
4150}
4151
4152fn cmd_relay_server(bind: &str, local_only: bool, uds: Option<&std::path::Path>) -> Result<()> {
4153 if let Some(socket_path) = uds {
4158 let base = if let Ok(home) = std::env::var("WIRE_HOME") {
4159 std::path::PathBuf::from(home)
4160 .join("state")
4161 .join("wire-relay")
4162 .join("uds")
4163 } else {
4164 dirs::state_dir()
4165 .or_else(dirs::data_local_dir)
4166 .ok_or_else(|| anyhow::anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?
4167 .join("wire-relay")
4168 .join("uds")
4169 };
4170 let runtime = tokio::runtime::Builder::new_multi_thread()
4171 .enable_all()
4172 .build()?;
4173 return runtime.block_on(crate::relay_server::serve_uds(
4174 socket_path.to_path_buf(),
4175 base,
4176 ));
4177 }
4178 if local_only {
4182 validate_loopback_bind(bind)?;
4183 }
4184 let base = if let Ok(home) = std::env::var("WIRE_HOME") {
4190 std::path::PathBuf::from(home)
4191 .join("state")
4192 .join("wire-relay")
4193 } else {
4194 dirs::state_dir()
4195 .or_else(dirs::data_local_dir)
4196 .ok_or_else(|| anyhow::anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?
4197 .join("wire-relay")
4198 };
4199 let state_dir = if local_only { base.join("local") } else { base };
4200 let runtime = tokio::runtime::Builder::new_multi_thread()
4201 .enable_all()
4202 .build()?;
4203 runtime.block_on(crate::relay_server::serve_with_mode(
4204 bind,
4205 state_dir,
4206 crate::relay_server::ServerMode { local_only },
4207 ))
4208}
4209
4210fn validate_loopback_bind(bind: &str) -> Result<()> {
4228 let host = if let Some(stripped) = bind.strip_prefix('[') {
4230 let close = stripped
4231 .find(']')
4232 .ok_or_else(|| anyhow::anyhow!("malformed IPv6 bind {bind:?}"))?;
4233 stripped[..close].to_string()
4234 } else {
4235 bind.rsplit_once(':')
4236 .map(|(h, _)| h.to_string())
4237 .unwrap_or_else(|| bind.to_string())
4238 };
4239 use std::net::{IpAddr, ToSocketAddrs};
4240 let probe = format!("{host}:0");
4241 let resolved: Vec<_> = probe
4242 .to_socket_addrs()
4243 .with_context(|| format!("resolving bind host {host:?}"))?
4244 .collect();
4245 if resolved.is_empty() {
4246 bail!("--local-only: bind host {host:?} resolved to no addresses");
4247 }
4248 for addr in &resolved {
4249 let ip = addr.ip();
4250 let is_acceptable = match ip {
4251 IpAddr::V4(v4) => {
4252 v4.is_loopback() || v4.is_private() || {
4253 let octets = v4.octets();
4255 octets[0] == 100 && (64..=127).contains(&octets[1])
4256 }
4257 }
4258 IpAddr::V6(v6) => v6.is_loopback(), };
4260 if !is_acceptable {
4261 bail!(
4262 "--local-only refuses non-private bind: {host:?} resolves to {} \
4263 which is not loopback (127/8, ::1), RFC 1918 private \
4264 (10/8, 172.16/12, 192.168/16), or RFC 6598 CGNAT/Tailscale \
4265 (100.64.0.0/10). Remove --local-only to bind publicly.",
4266 ip
4267 );
4268 }
4269 }
4270 Ok(())
4271}
4272
4273fn parse_scope(s: &str) -> Result<crate::endpoints::EndpointScope> {
4276 use crate::endpoints::EndpointScope;
4277 match s.to_lowercase().as_str() {
4278 "federation" | "fed" => Ok(EndpointScope::Federation),
4279 "local" => Ok(EndpointScope::Local),
4280 "lan" => Ok(EndpointScope::Lan),
4281 "uds" => Ok(EndpointScope::Uds),
4282 other => bail!("unknown --scope `{other}` (expected federation|local|lan|uds)"),
4283 }
4284}
4285
4286fn cmd_bind_relay(
4292 url: &str,
4293 scope: Option<&str>,
4294 replace: bool,
4295 migrate_pinned: bool,
4296 as_json: bool,
4297) -> Result<()> {
4298 use crate::endpoints::{Endpoint, self_endpoints};
4299
4300 if !config::is_initialized()? {
4301 bail!("not initialized — run `wire init <handle>` first");
4302 }
4303 let card = config::read_agent_card()?;
4304 let did = card.get("did").and_then(Value::as_str).unwrap_or("");
4305 let handle = crate::agent_card::display_handle_from_did(did).to_string();
4306
4307 let normalized = url.trim_end_matches('/');
4308 let new_scope = match scope {
4309 Some(s) => parse_scope(s)?,
4310 None => crate::endpoints::infer_scope_from_url(normalized),
4311 };
4312
4313 let existing = config::read_relay_state().unwrap_or_else(|_| json!({}));
4314 let pinned: Vec<String> = existing
4315 .get("peers")
4316 .and_then(|p| p.as_object())
4317 .map(|o| o.keys().cloned().collect())
4318 .unwrap_or_default();
4319
4320 let existing_eps = self_endpoints(&existing);
4321 let is_rebind_same = existing_eps.iter().any(|e| e.relay_url == normalized);
4322
4323 let destructive = replace || is_rebind_same;
4330 if destructive && !pinned.is_empty() && !migrate_pinned {
4331 let list = pinned.join(", ");
4332 let why = if replace {
4333 "`--replace` drops your other slot(s)"
4334 } else {
4335 "re-binding the same relay rotates its slot"
4336 };
4337 bail!(
4338 "bind-relay would black-hole {n} pinned peer(s): {list}. {why}; they are \
4339 pinned to your CURRENT slot and would keep pushing to a slot you no longer \
4340 read.\n\n\
4341 SAFE PATHS:\n\
4342 • Default (omit `--replace`) ADDITIVELY binds a NEW relay, keeping existing \
4343 slots — no black-hole.\n\
4344 • `wire rotate-slot` — same-relay rotation that emits wire_close to peers.\n\
4345 • `wire bind-relay {url} --migrate-pinned` — proceed anyway; re-pair each \
4346 peer out-of-band.\n\n\
4347 Issue #7 (silent black-hole on relay change) caught this.",
4348 n = pinned.len(),
4349 );
4350 }
4351
4352 let client = crate::relay_client::RelayClient::new(normalized);
4353 client.check_healthz()?;
4354 let alloc = client.allocate_slot(Some(&handle))?;
4355
4356 if destructive && !pinned.is_empty() {
4357 eprintln!(
4358 "wire bind-relay: {mode} with {n} pinned peer(s) — they will black-hole \
4359 until they re-pin: {peers}",
4360 mode = if replace { "replacing" } else { "rotating" },
4361 n = pinned.len(),
4362 peers = pinned.join(", "),
4363 );
4364 }
4365
4366 let mut state = existing;
4370 if replace {
4371 state["self"] = Value::Null;
4372 }
4373 crate::endpoints::upsert_self_endpoint(
4374 &mut state,
4375 Endpoint {
4376 relay_url: normalized.to_string(),
4377 slot_id: alloc.slot_id.clone(),
4378 slot_token: alloc.slot_token.clone(),
4379 scope: new_scope,
4380 },
4381 );
4382 config::write_relay_state(&state)?;
4383 let eps = self_endpoints(&state);
4384
4385 let scope_str = format!("{new_scope:?}").to_lowercase();
4386 if as_json {
4387 println!(
4388 "{}",
4389 serde_json::to_string(&json!({
4390 "relay_url": normalized,
4391 "slot_id": alloc.slot_id,
4392 "scope": scope_str,
4393 "endpoints": eps.len(),
4394 "additive": !replace,
4395 "slot_token_present": true,
4396 }))?
4397 );
4398 } else {
4399 println!(
4400 "bound {scope_str} slot on {normalized} (slot {})",
4401 alloc.slot_id
4402 );
4403 println!(
4404 "self now has {n} endpoint(s): {list}",
4405 n = eps.len(),
4406 list = eps
4407 .iter()
4408 .map(|e| format!("{}({:?})", e.relay_url, e.scope))
4409 .collect::<Vec<_>>()
4410 .join(", "),
4411 );
4412 }
4413 Ok(())
4414}
4415
4416fn cmd_add_peer_slot(
4419 handle: &str,
4420 url: &str,
4421 slot_id: &str,
4422 slot_token: &str,
4423 as_json: bool,
4424) -> Result<()> {
4425 use crate::endpoints::{Endpoint, infer_scope_from_url, pin_peer_endpoints};
4426 let mut state = config::read_relay_state()?;
4427
4428 let new_ep = Endpoint {
4435 relay_url: url.to_string(),
4436 slot_id: slot_id.to_string(),
4437 slot_token: slot_token.to_string(),
4438 scope: infer_scope_from_url(url),
4439 };
4440 let mut endpoints: Vec<Endpoint> = state
4441 .get("peers")
4442 .and_then(|p| p.get(handle))
4443 .and_then(|e| e.get("endpoints"))
4444 .and_then(|a| serde_json::from_value::<Vec<Endpoint>>(a.clone()).ok())
4445 .unwrap_or_default();
4446 if endpoints.is_empty()
4448 && let Some(peer) = state.get("peers").and_then(|p| p.get(handle))
4449 && let (Some(ru), Some(si), Some(st)) = (
4450 peer.get("relay_url").and_then(Value::as_str),
4451 peer.get("slot_id").and_then(Value::as_str),
4452 peer.get("slot_token").and_then(Value::as_str),
4453 )
4454 {
4455 endpoints.push(Endpoint {
4456 relay_url: ru.to_string(),
4457 slot_id: si.to_string(),
4458 slot_token: st.to_string(),
4459 scope: infer_scope_from_url(ru),
4460 });
4461 }
4462 if let Some(existing) = endpoints
4464 .iter_mut()
4465 .find(|e| e.relay_url == new_ep.relay_url)
4466 {
4467 *existing = new_ep;
4468 } else {
4469 endpoints.push(new_ep);
4470 }
4471 let n = endpoints.len();
4472 pin_peer_endpoints(&mut state, handle, &endpoints)?;
4473 config::write_relay_state(&state)?;
4474 if as_json {
4475 println!(
4476 "{}",
4477 serde_json::to_string(&json!({
4478 "handle": handle,
4479 "relay_url": url,
4480 "slot_id": slot_id,
4481 "added": true,
4482 "endpoint_count": n,
4483 }))?
4484 );
4485 } else {
4486 println!(
4487 "pinned peer slot for {handle} at {url} ({slot_id}) — peer now has {n} endpoint(s)"
4488 );
4489 }
4490 Ok(())
4491}
4492
4493fn cmd_push(peer_filter: Option<&str>, as_json: bool) -> Result<()> {
4496 let state = config::read_relay_state()?;
4497 let peers = state["peers"].as_object().cloned().unwrap_or_default();
4498 if peers.is_empty() {
4499 bail!(
4500 "no peer slots pinned — run `wire add-peer-slot <handle> <url> <slot_id> <token>` first"
4501 );
4502 }
4503 let outbox_dir = config::outbox_dir()?;
4504 if outbox_dir.exists() {
4509 let pinned: std::collections::HashSet<String> = peers.keys().cloned().collect();
4510 for entry in std::fs::read_dir(&outbox_dir)?.flatten() {
4511 let path = entry.path();
4512 if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
4513 continue;
4514 }
4515 let stem = match path.file_stem().and_then(|s| s.to_str()) {
4516 Some(s) => s.to_string(),
4517 None => continue,
4518 };
4519 if pinned.contains(&stem) {
4520 continue;
4521 }
4522 let bare = crate::agent_card::bare_handle(&stem);
4525 if pinned.contains(bare) {
4526 eprintln!(
4527 "wire push: WARN stale outbox file `{}.jsonl` not enumerated (pinned peer is `{bare}`). \
4528 Merge with: `cat {} >> {}` then delete the FQDN file.",
4529 stem,
4530 path.display(),
4531 outbox_dir.join(format!("{bare}.jsonl")).display(),
4532 );
4533 }
4534 }
4535 }
4536 if !outbox_dir.exists() {
4537 if as_json {
4538 println!(
4539 "{}",
4540 serde_json::to_string(&json!({"pushed": [], "skipped": []}))?
4541 );
4542 } else {
4543 println!("phyllis: nothing to dial out — write a message first with `wire send`");
4544 }
4545 return Ok(());
4546 }
4547
4548 let mut pushed = Vec::new();
4549 let mut skipped = Vec::new();
4550
4551 for (peer_handle, _) in peers.iter() {
4557 if let Some(want) = peer_filter
4558 && peer_handle != want
4559 {
4560 continue;
4561 }
4562 let outbox = outbox_dir.join(format!("{peer_handle}.jsonl"));
4563 if !outbox.exists() {
4564 continue;
4565 }
4566 let ordered_endpoints =
4567 crate::endpoints::peer_endpoints_in_priority_order(&state, peer_handle);
4568 if ordered_endpoints.is_empty() {
4569 for line in std::fs::read_to_string(&outbox).unwrap_or_default().lines() {
4573 let event: Value = match serde_json::from_str(line) {
4574 Ok(v) => v,
4575 Err(_) => continue,
4576 };
4577 let event_id = event
4578 .get("event_id")
4579 .and_then(Value::as_str)
4580 .unwrap_or("")
4581 .to_string();
4582 skipped.push(json!({
4583 "peer": peer_handle,
4584 "event_id": event_id,
4585 "reason": "no reachable endpoint pinned for peer",
4586 }));
4587 }
4588 continue;
4589 }
4590 let body = std::fs::read_to_string(&outbox)?;
4591 for line in body.lines() {
4592 let event: Value = match serde_json::from_str(line) {
4593 Ok(v) => v,
4594 Err(_) => continue,
4595 };
4596 let event_id = event
4597 .get("event_id")
4598 .and_then(Value::as_str)
4599 .unwrap_or("")
4600 .to_string();
4601
4602 let mut delivered = false;
4603 let mut last_err_reason: Option<String> = None;
4604 for endpoint in &ordered_endpoints {
4605 let client = crate::relay_client::RelayClient::new(&endpoint.relay_url);
4606 match client.post_event(&endpoint.slot_id, &endpoint.slot_token, &event) {
4607 Ok(resp) => {
4608 if resp.status == "duplicate" {
4609 skipped.push(json!({
4610 "peer": peer_handle,
4611 "event_id": event_id,
4612 "reason": "duplicate",
4613 "endpoint": endpoint.relay_url,
4614 "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
4615 }));
4616 } else {
4617 pushed.push(json!({
4618 "peer": peer_handle,
4619 "event_id": event_id,
4620 "endpoint": endpoint.relay_url,
4621 "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
4622 }));
4623 }
4624 delivered = true;
4625 break;
4626 }
4627 Err(e) => {
4628 last_err_reason = Some(crate::relay_client::format_transport_error(&e));
4633 }
4634 }
4635 }
4636 if !delivered {
4637 skipped.push(json!({
4638 "peer": peer_handle,
4639 "event_id": event_id,
4640 "reason": last_err_reason.unwrap_or_else(|| "all endpoints failed".to_string()),
4641 }));
4642 }
4643 }
4644 }
4645
4646 if as_json {
4647 println!(
4648 "{}",
4649 serde_json::to_string(&json!({"pushed": pushed, "skipped": skipped}))?
4650 );
4651 } else {
4652 println!(
4653 "pushed {} event(s); skipped {} ({})",
4654 pushed.len(),
4655 skipped.len(),
4656 if skipped.is_empty() {
4657 "none"
4658 } else {
4659 "see --json for detail"
4660 }
4661 );
4662 }
4663 Ok(())
4664}
4665
4666fn cmd_pull(as_json: bool) -> Result<()> {
4669 let state = config::read_relay_state()?;
4670 let self_state = state.get("self").cloned().unwrap_or(Value::Null);
4671 if self_state.is_null() {
4672 bail!("self slot not bound — run `wire bind-relay <url>` first");
4673 }
4674
4675 let endpoints = crate::endpoints::self_endpoints(&state);
4684 if endpoints.is_empty() {
4685 bail!("self.relay_url / slot_id / slot_token missing in relay_state.json");
4686 }
4687
4688 let inbox_dir = config::inbox_dir()?;
4689 config::ensure_dirs()?;
4690
4691 let mut total_seen = 0usize;
4692 let mut all_written: Vec<Value> = Vec::new();
4693 let mut all_rejected: Vec<Value> = Vec::new();
4694 let mut all_blocked = false;
4695 let mut all_advance_cursor_to: Option<String> = None;
4696
4697 for endpoint in &endpoints {
4698 let cursor_key = endpoint_cursor_key(endpoint.scope);
4699 let last_event_id = self_state
4700 .get(&cursor_key)
4701 .and_then(Value::as_str)
4702 .map(str::to_string);
4703 let client = crate::relay_client::RelayClient::new(&endpoint.relay_url);
4704 let events = match client.list_events(
4705 &endpoint.slot_id,
4706 &endpoint.slot_token,
4707 last_event_id.as_deref(),
4708 Some(1000),
4709 ) {
4710 Ok(ev) => ev,
4711 Err(e) => {
4712 eprintln!(
4716 "wire pull: endpoint {} ({:?}) errored: {}; continuing",
4717 endpoint.relay_url,
4718 endpoint.scope,
4719 crate::relay_client::format_transport_error(&e),
4720 );
4721 continue;
4722 }
4723 };
4724 total_seen += events.len();
4725 let result = crate::pull::process_events(&events, last_event_id.clone(), &inbox_dir)?;
4726 all_written.extend(result.written.iter().cloned());
4727 all_rejected.extend(result.rejected.iter().cloned());
4728 if result.blocked {
4729 all_blocked = true;
4730 }
4731 if let Some(eid) = result.advance_cursor_to.clone() {
4734 if endpoint.scope == crate::endpoints::EndpointScope::Federation {
4735 all_advance_cursor_to = Some(eid.clone());
4736 }
4737 let key = cursor_key.clone();
4738 config::update_relay_state(|state| {
4739 if let Some(self_obj) = state.get_mut("self").and_then(Value::as_object_mut) {
4740 self_obj.insert(key, Value::String(eid));
4741 }
4742 Ok(())
4743 })?;
4744 }
4745 }
4746
4747 let result = crate::pull::PullResult {
4752 written: all_written,
4753 rejected: all_rejected,
4754 blocked: all_blocked,
4755 advance_cursor_to: all_advance_cursor_to,
4756 };
4757 let events_len = total_seen;
4758
4759 if as_json {
4763 println!(
4764 "{}",
4765 serde_json::to_string(&json!({
4766 "written": result.written,
4767 "rejected": result.rejected,
4768 "total_seen": events_len,
4769 "cursor_blocked": result.blocked,
4770 "cursor_advanced_to": result.advance_cursor_to,
4771 }))?
4772 );
4773 } else {
4774 let blocking = result
4775 .rejected
4776 .iter()
4777 .filter(|r| r.get("blocks_cursor").and_then(Value::as_bool) == Some(true))
4778 .count();
4779 if blocking > 0 {
4780 println!(
4781 "pulled {} event(s); wrote {}; rejected {} ({} BLOCKING cursor — see `wire pull --json`)",
4782 events_len,
4783 result.written.len(),
4784 result.rejected.len(),
4785 blocking,
4786 );
4787 } else {
4788 println!(
4789 "pulled {} event(s); wrote {}; rejected {}",
4790 events_len,
4791 result.written.len(),
4792 result.rejected.len(),
4793 );
4794 }
4795 }
4796 Ok(())
4797}
4798
4799fn endpoint_cursor_key(scope: crate::endpoints::EndpointScope) -> String {
4804 match scope {
4805 crate::endpoints::EndpointScope::Federation => "last_pulled_event_id".to_string(),
4806 crate::endpoints::EndpointScope::Local => "last_pulled_event_id_local".to_string(),
4807 crate::endpoints::EndpointScope::Lan => "last_pulled_event_id_lan".to_string(),
4808 crate::endpoints::EndpointScope::Uds => "last_pulled_event_id_uds".to_string(),
4809 }
4810}
4811
4812fn cmd_rotate_slot(no_announce: bool, as_json: bool) -> Result<()> {
4815 if !config::is_initialized()? {
4816 bail!("not initialized — run `wire init <handle>` first");
4817 }
4818 let mut state = config::read_relay_state()?;
4819 let self_state = state.get("self").cloned().unwrap_or(Value::Null);
4820 if self_state.is_null() {
4821 bail!("self slot not bound — run `wire bind-relay <url>` first (nothing to rotate)");
4822 }
4823 let primary = crate::endpoints::self_primary_endpoint(&state)
4827 .ok_or_else(|| anyhow!("self has no resolvable inbound endpoint to rotate"))?;
4828 let url = primary.relay_url.clone();
4829 let old_slot_id = primary.slot_id.clone();
4830 let old_slot_token = primary.slot_token.clone();
4831
4832 let card = config::read_agent_card()?;
4834 let did = card
4835 .get("did")
4836 .and_then(Value::as_str)
4837 .unwrap_or("")
4838 .to_string();
4839 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
4840 let pk_b64 = card
4841 .get("verify_keys")
4842 .and_then(Value::as_object)
4843 .and_then(|m| m.values().next())
4844 .and_then(|v| v.get("key"))
4845 .and_then(Value::as_str)
4846 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?
4847 .to_string();
4848 let pk_bytes = crate::signing::b64decode(&pk_b64)?;
4849 let sk_seed = config::read_private_key()?;
4850
4851 let normalized = url.trim_end_matches('/').to_string();
4853 let client = crate::relay_client::RelayClient::new(&normalized);
4854 client
4855 .check_healthz()
4856 .context("aborting rotation; old slot still valid")?;
4857 let alloc = client.allocate_slot(Some(&handle))?;
4858 let new_slot_id = alloc.slot_id.clone();
4859 let new_slot_token = alloc.slot_token.clone();
4860
4861 let mut announced: Vec<String> = Vec::new();
4868 if !no_announce {
4869 let now = time::OffsetDateTime::now_utc()
4870 .format(&time::format_description::well_known::Rfc3339)
4871 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
4872 let body = json!({
4873 "reason": "operator-initiated slot rotation",
4874 "new_relay_url": url,
4875 "new_slot_id": new_slot_id,
4876 });
4880 let peers = state["peers"].as_object().cloned().unwrap_or_default();
4881 for (peer_handle, _peer_info) in peers.iter() {
4882 let event = json!({
4883 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
4884 "timestamp": now.clone(),
4885 "from": did,
4886 "to": format!("did:wire:{peer_handle}"),
4887 "type": "wire_close",
4888 "kind": 1201,
4889 "body": body.clone(),
4890 });
4891 let signed = match sign_message_v31(&event, &sk_seed, &pk_bytes, &handle) {
4892 Ok(s) => s,
4893 Err(e) => {
4894 eprintln!("warn: could not sign wire_close for {peer_handle}: {e}");
4895 continue;
4896 }
4897 };
4898 let peer_info = match state["peers"].get(peer_handle) {
4903 Some(p) => p.clone(),
4904 None => continue,
4905 };
4906 let peer_url = peer_info["relay_url"].as_str().unwrap_or(&url);
4907 let peer_slot_id = peer_info["slot_id"].as_str().unwrap_or("");
4908 let peer_slot_token = peer_info["slot_token"].as_str().unwrap_or("");
4909 if peer_slot_id.is_empty() || peer_slot_token.is_empty() {
4910 continue;
4911 }
4912 let peer_client = if peer_url == url {
4913 client.clone()
4914 } else {
4915 crate::relay_client::RelayClient::new(peer_url)
4916 };
4917 match peer_client.post_event(peer_slot_id, peer_slot_token, &signed) {
4918 Ok(_) => announced.push(peer_handle.clone()),
4919 Err(e) => eprintln!("warn: announce to {peer_handle} failed: {e}"),
4920 }
4921 }
4922 }
4923
4924 state["self"] = json!({
4926 "relay_url": url,
4927 "slot_id": new_slot_id,
4928 "slot_token": new_slot_token,
4929 });
4930 config::write_relay_state(&state)?;
4931
4932 if as_json {
4933 println!(
4934 "{}",
4935 serde_json::to_string(&json!({
4936 "rotated": true,
4937 "old_slot_id": old_slot_id,
4938 "new_slot_id": new_slot_id,
4939 "relay_url": url,
4940 "announced_to": announced,
4941 }))?
4942 );
4943 } else {
4944 println!("rotated slot on {url}");
4945 println!(
4946 " old slot_id: {old_slot_id} (orphaned — abusive bearer-holders lose their leverage)"
4947 );
4948 println!(" new slot_id: {new_slot_id}");
4949 if !announced.is_empty() {
4950 println!(
4951 " announced wire_close (kind=1201) to: {}",
4952 announced.join(", ")
4953 );
4954 }
4955 println!();
4956 println!("next steps:");
4957 println!(" - peers see the wire_close event in their next `wire pull`");
4958 println!(
4959 " - paired peers must re-issue: tell them to run `wire add-peer-slot {handle} {url} {new_slot_id} <new-token>`"
4960 );
4961 println!(" (or full re-pair via `wire pair-host`/`wire join`)");
4962 println!(" - until they do, you'll receive but they won't be able to reach you");
4963 let _ = old_slot_token;
4965 }
4966 Ok(())
4967}
4968
4969fn cmd_forget_peer(handle: &str, purge: bool, as_json: bool) -> Result<()> {
4972 let mut trust = config::read_trust()?;
4973 let mut removed_from_trust = false;
4974 if let Some(agents) = trust.get_mut("agents").and_then(Value::as_object_mut)
4975 && agents.remove(handle).is_some()
4976 {
4977 removed_from_trust = true;
4978 }
4979 config::write_trust(&trust)?;
4980
4981 let mut state = config::read_relay_state()?;
4982 let mut removed_from_relay = false;
4983 if let Some(peers) = state.get_mut("peers").and_then(Value::as_object_mut)
4984 && peers.remove(handle).is_some()
4985 {
4986 removed_from_relay = true;
4987 }
4988 config::write_relay_state(&state)?;
4989
4990 let mut purged: Vec<String> = Vec::new();
4991 if purge {
4992 for dir in [config::inbox_dir()?, config::outbox_dir()?] {
4993 let path = dir.join(format!("{handle}.jsonl"));
4994 if path.exists() {
4995 std::fs::remove_file(&path).with_context(|| format!("removing {path:?}"))?;
4996 purged.push(path.to_string_lossy().into());
4997 }
4998 }
4999 }
5000
5001 if !removed_from_trust && !removed_from_relay {
5002 if as_json {
5003 println!(
5004 "{}",
5005 serde_json::to_string(&json!({
5006 "removed": false,
5007 "reason": format!("peer {handle:?} not pinned"),
5008 }))?
5009 );
5010 } else {
5011 eprintln!("peer {handle:?} not found in trust or relay state — nothing to forget");
5012 }
5013 return Ok(());
5014 }
5015
5016 if as_json {
5017 println!(
5018 "{}",
5019 serde_json::to_string(&json!({
5020 "handle": handle,
5021 "removed_from_trust": removed_from_trust,
5022 "removed_from_relay_state": removed_from_relay,
5023 "purged_files": purged,
5024 }))?
5025 );
5026 } else {
5027 println!("forgot peer {handle:?}");
5028 if removed_from_trust {
5029 println!(" - removed from trust.json");
5030 }
5031 if removed_from_relay {
5032 println!(" - removed from relay.json");
5033 }
5034 if !purged.is_empty() {
5035 for p in &purged {
5036 println!(" - deleted {p}");
5037 }
5038 } else if !purge {
5039 println!(" (inbox/outbox files preserved; pass --purge to delete them)");
5040 }
5041 }
5042 Ok(())
5043}
5044
5045fn cmd_daemon(interval_secs: u64, once: bool, as_json: bool) -> Result<()> {
5048 if !config::is_initialized()? {
5049 bail!("not initialized — run `wire init <handle>` first");
5050 }
5051 let interval = std::time::Duration::from_secs(interval_secs.max(1));
5052
5053 if !as_json {
5054 if once {
5055 eprintln!("wire daemon: single sync cycle, then exit");
5056 } else {
5057 eprintln!("wire daemon: syncing every {interval_secs}s. SIGINT to stop.");
5058 }
5059 }
5060
5061 if let Err(e) = crate::pending_pair::cleanup_on_startup() {
5065 eprintln!("daemon: pending-pair cleanup_on_startup error: {e:#}");
5066 }
5067
5068 let (wake_tx, wake_rx) = std::sync::mpsc::channel::<()>();
5074 if !once {
5075 crate::daemon_stream::spawn_stream_subscriber(wake_tx);
5076 }
5077
5078 loop {
5079 let pushed = run_sync_push().unwrap_or_else(|e| {
5080 eprintln!("daemon: push error: {e:#}");
5081 json!({"pushed": [], "skipped": [{"error": e.to_string()}]})
5082 });
5083 let pulled = run_sync_pull().unwrap_or_else(|e| {
5084 eprintln!("daemon: pull error: {e:#}");
5085 json!({"written": [], "rejected": [], "total_seen": 0, "error": e.to_string()})
5086 });
5087 let pairs = crate::pending_pair::tick().unwrap_or_else(|e| {
5088 eprintln!("daemon: pending-pair tick error: {e:#}");
5089 json!({"transitions": []})
5090 });
5091
5092 if as_json {
5093 println!(
5094 "{}",
5095 serde_json::to_string(&json!({
5096 "ts": time::OffsetDateTime::now_utc()
5097 .format(&time::format_description::well_known::Rfc3339)
5098 .unwrap_or_default(),
5099 "push": pushed,
5100 "pull": pulled,
5101 "pairs": pairs,
5102 }))?
5103 );
5104 } else {
5105 let pushed_n = pushed["pushed"].as_array().map(|a| a.len()).unwrap_or(0);
5106 let written_n = pulled["written"].as_array().map(|a| a.len()).unwrap_or(0);
5107 let rejected_n = pulled["rejected"].as_array().map(|a| a.len()).unwrap_or(0);
5108 let pair_transitions = pairs["transitions"]
5109 .as_array()
5110 .map(|a| a.len())
5111 .unwrap_or(0);
5112 if pushed_n > 0 || written_n > 0 || rejected_n > 0 || pair_transitions > 0 {
5113 eprintln!(
5114 "daemon: pushed={pushed_n} pulled={written_n} rejected={rejected_n} pair-transitions={pair_transitions}"
5115 );
5116 }
5117 if let Some(arr) = pairs["transitions"].as_array() {
5119 for t in arr {
5120 eprintln!(
5121 " pair {} : {} → {}",
5122 t.get("code").and_then(Value::as_str).unwrap_or("?"),
5123 t.get("from").and_then(Value::as_str).unwrap_or("?"),
5124 t.get("to").and_then(Value::as_str).unwrap_or("?")
5125 );
5126 if let Some(sas) = t.get("sas").and_then(Value::as_str)
5127 && t.get("to").and_then(Value::as_str) == Some("sas_ready")
5128 {
5129 eprintln!(" SAS digits: {}-{}", &sas[..3], &sas[3..]);
5130 eprintln!(
5131 " Run: wire pair-confirm {} {}",
5132 t.get("code").and_then(Value::as_str).unwrap_or("?"),
5133 sas
5134 );
5135 }
5136 }
5137 }
5138 }
5139
5140 if once {
5141 return Ok(());
5142 }
5143 match wake_rx.recv_timeout(interval) {
5156 Ok(()) | Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
5157 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
5158 std::thread::sleep(interval);
5159 }
5160 }
5161 while wake_rx.try_recv().is_ok() {}
5162 }
5163}
5164
5165fn run_sync_push() -> Result<Value> {
5168 let state = config::read_relay_state()?;
5169 let peers = state["peers"].as_object().cloned().unwrap_or_default();
5170 if peers.is_empty() {
5171 return Ok(json!({"pushed": [], "skipped": []}));
5172 }
5173 let outbox_dir = config::outbox_dir()?;
5174 if !outbox_dir.exists() {
5175 return Ok(json!({"pushed": [], "skipped": []}));
5176 }
5177 let mut pushed = Vec::new();
5178 let mut skipped = Vec::new();
5179 for (peer_handle, slot_info) in peers.iter() {
5180 let outbox = outbox_dir.join(format!("{peer_handle}.jsonl"));
5181 if !outbox.exists() {
5182 continue;
5183 }
5184 let url = slot_info["relay_url"].as_str().unwrap_or("");
5185 let slot_id = slot_info["slot_id"].as_str().unwrap_or("");
5186 let slot_token = slot_info["slot_token"].as_str().unwrap_or("");
5187 if url.is_empty() || slot_id.is_empty() || slot_token.is_empty() {
5188 continue;
5189 }
5190 let client = crate::relay_client::RelayClient::new(url);
5191 let body = std::fs::read_to_string(&outbox)?;
5192 for line in body.lines() {
5193 let event: Value = match serde_json::from_str(line) {
5194 Ok(v) => v,
5195 Err(_) => continue,
5196 };
5197 let event_id = event
5198 .get("event_id")
5199 .and_then(Value::as_str)
5200 .unwrap_or("")
5201 .to_string();
5202 match client.post_event(slot_id, slot_token, &event) {
5203 Ok(resp) => {
5204 if resp.status == "duplicate" {
5205 skipped.push(json!({"peer": peer_handle, "event_id": event_id, "reason": "duplicate"}));
5206 } else {
5207 pushed.push(json!({"peer": peer_handle, "event_id": event_id}));
5208 }
5209 }
5210 Err(e) => {
5211 let reason = crate::relay_client::format_transport_error(&e);
5215 skipped
5216 .push(json!({"peer": peer_handle, "event_id": event_id, "reason": reason}));
5217 }
5218 }
5219 }
5220 }
5221 Ok(json!({"pushed": pushed, "skipped": skipped}))
5222}
5223
5224fn run_sync_pull() -> Result<Value> {
5232 let state = config::read_relay_state()?;
5233 if state.get("self").map(Value::is_null).unwrap_or(true) {
5234 return Ok(json!({"written": [], "rejected": [], "total_seen": 0}));
5235 }
5236 let endpoints = crate::endpoints::self_endpoints(&state);
5243 if endpoints.is_empty() {
5244 return Ok(json!({"written": [], "rejected": [], "total_seen": 0}));
5245 }
5246 let inbox_dir = config::inbox_dir()?;
5247 config::ensure_dirs()?;
5248
5249 let self_obj = state.get("self").cloned().unwrap_or(Value::Null);
5254 let legacy_cursor = self_obj
5255 .get("last_pulled_event_id")
5256 .and_then(Value::as_str)
5257 .map(str::to_string);
5258 let primary_slot = crate::endpoints::self_primary_endpoint(&state).map(|e| e.slot_id);
5259 let mut cursors: serde_json::Map<String, Value> = self_obj
5260 .get("cursors")
5261 .and_then(Value::as_object)
5262 .cloned()
5263 .unwrap_or_default();
5264
5265 let mut all_written: Vec<Value> = Vec::new();
5266 let mut all_rejected: Vec<Value> = Vec::new();
5267 let mut total_seen = 0usize;
5268 let mut blocked_any = false;
5269
5270 for ep in &endpoints {
5271 if ep.relay_url.is_empty() {
5272 continue;
5273 }
5274 let cursor = cursors
5275 .get(&ep.slot_id)
5276 .and_then(Value::as_str)
5277 .map(str::to_string)
5278 .or_else(|| {
5279 if Some(&ep.slot_id) == primary_slot.as_ref() {
5280 legacy_cursor.clone()
5281 } else {
5282 None
5283 }
5284 });
5285 let client = crate::relay_client::RelayClient::new(&ep.relay_url);
5286 let events =
5289 match client.list_events(&ep.slot_id, &ep.slot_token, cursor.as_deref(), Some(1000)) {
5290 Ok(e) => e,
5291 Err(e) => {
5292 eprintln!(
5293 "daemon: pull error on {} slot {} (continuing): {e:#}",
5294 ep.relay_url, ep.slot_id
5295 );
5296 continue;
5297 }
5298 };
5299 total_seen += events.len();
5300 let result = crate::pull::process_events(&events, cursor, &inbox_dir)?;
5303 if let Some(eid) = &result.advance_cursor_to {
5304 cursors.insert(ep.slot_id.clone(), Value::String(eid.clone()));
5305 }
5306 blocked_any |= result.blocked;
5307 all_written.extend(result.written);
5308 all_rejected.extend(result.rejected);
5309 }
5310
5311 let primary_cursor = primary_slot
5315 .as_ref()
5316 .and_then(|s| cursors.get(s))
5317 .and_then(Value::as_str)
5318 .map(str::to_string);
5319 config::update_relay_state(|state| {
5320 if let Some(self_obj) = state.get_mut("self").and_then(Value::as_object_mut) {
5321 self_obj.insert("cursors".into(), Value::Object(cursors.clone()));
5322 if let Some(pc) = &primary_cursor {
5323 self_obj.insert("last_pulled_event_id".into(), Value::String(pc.clone()));
5324 }
5325 }
5326 Ok(())
5327 })?;
5328
5329 Ok(json!({
5330 "written": all_written,
5331 "rejected": all_rejected,
5332 "total_seen": total_seen,
5333 "cursor_blocked": blocked_any,
5334 "endpoints_pulled": endpoints.len(),
5335 }))
5336}
5337
5338fn cmd_pin(card_file: &str, as_json: bool) -> Result<()> {
5341 let body =
5342 std::fs::read_to_string(card_file).with_context(|| format!("reading {card_file}"))?;
5343 let card: Value =
5344 serde_json::from_str(&body).with_context(|| format!("parsing {card_file}"))?;
5345 crate::agent_card::verify_agent_card(&card)
5346 .map_err(|e| anyhow!("peer card signature invalid: {e}"))?;
5347
5348 let mut trust = config::read_trust()?;
5349 crate::trust::add_agent_card_pin(&mut trust, &card, Some("VERIFIED"));
5350
5351 let did = card.get("did").and_then(Value::as_str).unwrap_or("");
5352 let handle = crate::agent_card::display_handle_from_did(did).to_string();
5353 config::write_trust(&trust)?;
5354
5355 if as_json {
5356 println!(
5357 "{}",
5358 serde_json::to_string(&json!({
5359 "handle": handle,
5360 "did": did,
5361 "tier": "VERIFIED",
5362 "pinned": true,
5363 }))?
5364 );
5365 } else {
5366 println!("pinned {handle} ({did}) at tier VERIFIED");
5367 }
5368 Ok(())
5369}
5370
5371fn cmd_pair_host(relay_url: &str, auto_yes: bool, timeout_secs: u64) -> Result<()> {
5374 pair_orchestrate(relay_url, None, "host", auto_yes, timeout_secs)
5375}
5376
5377fn cmd_pair_join(
5378 code_phrase: &str,
5379 relay_url: &str,
5380 auto_yes: bool,
5381 timeout_secs: u64,
5382) -> Result<()> {
5383 pair_orchestrate(
5384 relay_url,
5385 Some(code_phrase),
5386 "guest",
5387 auto_yes,
5388 timeout_secs,
5389 )
5390}
5391
5392fn pair_orchestrate(
5398 relay_url: &str,
5399 code_in: Option<&str>,
5400 role: &str,
5401 auto_yes: bool,
5402 timeout_secs: u64,
5403) -> Result<()> {
5404 use crate::pair_session::{pair_session_finalize, pair_session_open, pair_session_try_sas};
5405
5406 let mut s = pair_session_open(role, relay_url, code_in)?;
5407
5408 if role == "host" {
5409 eprintln!();
5410 eprintln!("share this code phrase with your peer:");
5411 eprintln!();
5412 eprintln!(" {}", s.code);
5413 eprintln!();
5414 eprintln!(
5415 "waiting for peer to run `wire pair-join {} --relay {relay_url}` ...",
5416 s.code
5417 );
5418 } else {
5419 eprintln!();
5420 eprintln!("joined pair-slot on {relay_url} — waiting for host's SPAKE2 message ...");
5421 }
5422
5423 const HEARTBEAT_SECS: u64 = 10;
5428 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
5429 let started = std::time::Instant::now();
5430 let mut last_heartbeat = started;
5431 let formatted = loop {
5432 if let Some(sas) = pair_session_try_sas(&mut s)? {
5433 break sas;
5434 }
5435 let now = std::time::Instant::now();
5436 if now >= deadline {
5437 return Err(anyhow!(
5438 "timeout after {timeout_secs}s waiting for peer's SPAKE2 message"
5439 ));
5440 }
5441 if now.duration_since(last_heartbeat).as_secs() >= HEARTBEAT_SECS {
5442 let elapsed = now.duration_since(started).as_secs();
5443 eprintln!(" ... still waiting ({elapsed}s / {timeout_secs}s)");
5444 last_heartbeat = now;
5445 }
5446 std::thread::sleep(std::time::Duration::from_millis(250));
5447 };
5448
5449 eprintln!();
5450 eprintln!("SAS digits (must match peer's terminal):");
5451 eprintln!();
5452 eprintln!(" {formatted}");
5453 eprintln!();
5454
5455 if !auto_yes {
5458 eprint!("does this match your peer's terminal? [y/N]: ");
5459 use std::io::Write;
5460 std::io::stderr().flush().ok();
5461 let mut input = String::new();
5462 std::io::stdin().read_line(&mut input)?;
5463 let trimmed = input.trim().to_lowercase();
5464 if trimmed != "y" && trimmed != "yes" {
5465 bail!("SAS confirmation declined — aborting pairing");
5466 }
5467 }
5468 s.sas_confirmed = true;
5469
5470 let result = pair_session_finalize(&mut s, timeout_secs)?;
5472
5473 let peer_did = result["paired_with"].as_str().unwrap_or("");
5474 let peer_role = if role == "host" { "guest" } else { "host" };
5475 eprintln!("paired with {peer_did} (peer role: {peer_role})");
5476 eprintln!("peer card pinned at tier VERIFIED");
5477 eprintln!(
5478 "peer relay slot saved to {}",
5479 config::relay_state_path()?.display()
5480 );
5481
5482 println!("{}", serde_json::to_string(&result)?);
5483 Ok(())
5484}
5485
5486fn cmd_pair(
5492 handle: &str,
5493 code: Option<&str>,
5494 relay: &str,
5495 auto_yes: bool,
5496 timeout_secs: u64,
5497 no_setup: bool,
5498) -> Result<()> {
5499 let init_result = crate::pair_session::init_self_idempotent(handle, None, None)?;
5502 let did = init_result
5503 .get("did")
5504 .and_then(|v| v.as_str())
5505 .unwrap_or("(unknown)")
5506 .to_string();
5507 let already = init_result
5508 .get("already_initialized")
5509 .and_then(|v| v.as_bool())
5510 .unwrap_or(false);
5511 if already {
5512 println!("(identity {did} already initialized — reusing)");
5513 } else {
5514 println!("initialized {did}");
5515 }
5516 println!();
5517
5518 match code {
5520 None => {
5521 println!("hosting pair on {relay} (no code = host) ...");
5522 cmd_pair_host(relay, auto_yes, timeout_secs)?;
5523 }
5524 Some(c) => {
5525 println!("joining pair with code {c} on {relay} ...");
5526 cmd_pair_join(c, relay, auto_yes, timeout_secs)?;
5527 }
5528 }
5529
5530 if !no_setup {
5532 println!();
5533 println!("registering wire as MCP server in detected client configs ...");
5534 if let Err(e) = cmd_setup(true) {
5535 eprintln!("warn: setup --apply failed: {e}");
5537 eprintln!(" pair succeeded; you can re-run `wire setup --apply` manually.");
5538 }
5539 }
5540
5541 println!();
5542 println!("pair complete. Next steps:");
5543 println!(" wire daemon start # background sync of inbox/outbox vs relay");
5544 println!(" wire send <peer> claim <msg> # send your peer something");
5545 println!(" wire tail # watch incoming events");
5546 Ok(())
5547}
5548
5549fn cmd_pair_detach(handle: &str, code: Option<&str>, relay: &str) -> Result<()> {
5555 let init_result = crate::pair_session::init_self_idempotent(handle, None, None)?;
5556 let did = init_result
5557 .get("did")
5558 .and_then(|v| v.as_str())
5559 .unwrap_or("(unknown)")
5560 .to_string();
5561 let already = init_result
5562 .get("already_initialized")
5563 .and_then(|v| v.as_bool())
5564 .unwrap_or(false);
5565 if already {
5566 println!("(identity {did} already initialized — reusing)");
5567 } else {
5568 println!("initialized {did}");
5569 }
5570 println!();
5571 match code {
5572 None => cmd_pair_host_detach(relay, false),
5573 Some(c) => cmd_pair_join_detach(c, relay, false),
5574 }
5575}
5576
5577fn cmd_pair_host_detach(relay_url: &str, as_json: bool) -> Result<()> {
5578 if !config::is_initialized()? {
5579 bail!("not initialized — run `wire init <handle>` first");
5580 }
5581 let daemon_spawned = match crate::ensure_up::ensure_daemon_running() {
5582 Ok(b) => b,
5583 Err(e) => {
5584 if !as_json {
5585 eprintln!(
5586 "warn: could not auto-start daemon: {e}; pair will queue but not advance"
5587 );
5588 }
5589 false
5590 }
5591 };
5592 let code = crate::sas::generate_code_phrase();
5593 let code_hash = crate::pair_session::derive_code_hash(&code);
5594 let now = time::OffsetDateTime::now_utc()
5595 .format(&time::format_description::well_known::Rfc3339)
5596 .unwrap_or_default();
5597 let p = crate::pending_pair::PendingPair {
5598 code: code.clone(),
5599 code_hash,
5600 role: "host".to_string(),
5601 relay_url: relay_url.to_string(),
5602 status: "request_host".to_string(),
5603 sas: None,
5604 peer_did: None,
5605 created_at: now,
5606 last_error: None,
5607 pair_id: None,
5608 our_slot_id: None,
5609 our_slot_token: None,
5610 spake2_seed_b64: None,
5611 };
5612 crate::pending_pair::write_pending(&p)?;
5613 if as_json {
5614 println!(
5615 "{}",
5616 serde_json::to_string(&json!({
5617 "state": "queued",
5618 "code_phrase": code,
5619 "relay_url": relay_url,
5620 "role": "host",
5621 "daemon_spawned": daemon_spawned,
5622 }))?
5623 );
5624 } else {
5625 if daemon_spawned {
5626 println!("(started wire daemon in background)");
5627 }
5628 println!("detached pair-host queued. Share this code with your peer:\n");
5629 println!(" {code}\n");
5630 println!("Next steps:");
5631 println!(" wire pair-list # check status");
5632 println!(" wire pair-confirm {code} <digits> # when SAS shows up");
5633 println!(" wire pair-cancel {code} # to abort");
5634 }
5635 Ok(())
5636}
5637
5638fn cmd_pair_join_detach(code_phrase: &str, relay_url: &str, as_json: bool) -> Result<()> {
5639 if !config::is_initialized()? {
5640 bail!("not initialized — run `wire init <handle>` first");
5641 }
5642 let daemon_spawned = match crate::ensure_up::ensure_daemon_running() {
5643 Ok(b) => b,
5644 Err(e) => {
5645 if !as_json {
5646 eprintln!(
5647 "warn: could not auto-start daemon: {e}; pair will queue but not advance"
5648 );
5649 }
5650 false
5651 }
5652 };
5653 let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
5654 let code_hash = crate::pair_session::derive_code_hash(&code);
5655 let now = time::OffsetDateTime::now_utc()
5656 .format(&time::format_description::well_known::Rfc3339)
5657 .unwrap_or_default();
5658 let p = crate::pending_pair::PendingPair {
5659 code: code.clone(),
5660 code_hash,
5661 role: "guest".to_string(),
5662 relay_url: relay_url.to_string(),
5663 status: "request_guest".to_string(),
5664 sas: None,
5665 peer_did: None,
5666 created_at: now,
5667 last_error: None,
5668 pair_id: None,
5669 our_slot_id: None,
5670 our_slot_token: None,
5671 spake2_seed_b64: None,
5672 };
5673 crate::pending_pair::write_pending(&p)?;
5674 if as_json {
5675 println!(
5676 "{}",
5677 serde_json::to_string(&json!({
5678 "state": "queued",
5679 "code_phrase": code,
5680 "relay_url": relay_url,
5681 "role": "guest",
5682 "daemon_spawned": daemon_spawned,
5683 }))?
5684 );
5685 } else {
5686 if daemon_spawned {
5687 println!("(started wire daemon in background)");
5688 }
5689 println!("detached pair-join queued for code {code}.");
5690 println!(
5691 "Run `wire pair-list` to watch for SAS, then `wire pair-confirm {code} <digits>`."
5692 );
5693 }
5694 Ok(())
5695}
5696
5697fn cmd_pair_confirm(code_phrase: &str, typed_digits: &str, as_json: bool) -> Result<()> {
5698 let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
5699 let typed: String = typed_digits
5700 .chars()
5701 .filter(|c| c.is_ascii_digit())
5702 .collect();
5703 if typed.len() != 6 {
5704 bail!(
5705 "expected 6 digits (got {} after stripping non-digits)",
5706 typed.len()
5707 );
5708 }
5709 let mut p = crate::pending_pair::read_pending(&code)?
5710 .ok_or_else(|| anyhow!("no pending pair found for code {code}"))?;
5711 if p.status != "sas_ready" {
5712 bail!(
5713 "pair {code} not in sas_ready state (current: {}). Run `wire pair-list` to see what's going on.",
5714 p.status
5715 );
5716 }
5717 let stored = p
5718 .sas
5719 .as_ref()
5720 .ok_or_else(|| anyhow!("pending file has status=sas_ready but no sas field"))?
5721 .clone();
5722 if stored == typed {
5723 p.status = "confirmed".to_string();
5724 crate::pending_pair::write_pending(&p)?;
5725 if as_json {
5726 println!(
5727 "{}",
5728 serde_json::to_string(&json!({
5729 "state": "confirmed",
5730 "code_phrase": code,
5731 }))?
5732 );
5733 } else {
5734 println!("digits match. Daemon will finalize the handshake on its next tick.");
5735 println!("Run `wire peers` after a few seconds to confirm.");
5736 }
5737 } else {
5738 p.status = "aborted".to_string();
5739 p.last_error = Some(format!(
5740 "SAS digit mismatch (typed {typed}, expected {stored})"
5741 ));
5742 let client = crate::relay_client::RelayClient::new(&p.relay_url);
5743 let _ = client.pair_abandon(&p.code_hash);
5744 crate::pending_pair::write_pending(&p)?;
5745 crate::os_notify::toast(
5746 &format!("wire — pair aborted ({})", p.code),
5747 p.last_error.as_deref().unwrap_or("digits mismatch"),
5748 );
5749 if as_json {
5750 println!(
5751 "{}",
5752 serde_json::to_string(&json!({
5753 "state": "aborted",
5754 "code_phrase": code,
5755 "error": "digits mismatch",
5756 }))?
5757 );
5758 }
5759 bail!("digits mismatch — pair aborted. Re-issue with a fresh `wire pair-host --detach`.");
5760 }
5761 Ok(())
5762}
5763
5764fn cmd_pair_list(as_json: bool, watch: bool, watch_interval_secs: u64) -> Result<()> {
5765 if watch {
5766 return cmd_pair_list_watch(watch_interval_secs);
5767 }
5768 let spake2_items = crate::pending_pair::list_pending()?;
5769 let inbound_items = crate::pending_inbound_pair::list_pending_inbound()?;
5770 if as_json {
5771 println!("{}", serde_json::to_string(&spake2_items)?);
5776 return Ok(());
5777 }
5778 if spake2_items.is_empty() && inbound_items.is_empty() {
5779 println!("no pending pair sessions.");
5780 return Ok(());
5781 }
5782 if !inbound_items.is_empty() {
5785 println!("PENDING INBOUND (v0.5.14 zero-paste pair_drop awaiting your accept)");
5786 println!(
5787 "{:<20} {:<35} {:<25} NEXT STEP",
5788 "PEER", "RELAY", "RECEIVED"
5789 );
5790 for p in &inbound_items {
5791 println!(
5792 "{:<20} {:<35} {:<25} `wire pair-accept {peer}` to accept; `wire pair-reject {peer}` to refuse",
5793 p.peer_handle,
5794 p.peer_relay_url,
5795 p.received_at,
5796 peer = p.peer_handle,
5797 );
5798 }
5799 println!();
5800 }
5801 if !spake2_items.is_empty() {
5802 println!("SPAKE2 SESSIONS");
5803 println!(
5804 "{:<15} {:<8} {:<18} {:<10} NOTE",
5805 "CODE", "ROLE", "STATUS", "SAS"
5806 );
5807 for p in spake2_items {
5808 let sas = p
5809 .sas
5810 .as_ref()
5811 .map(|d| format!("{}-{}", &d[..3], &d[3..]))
5812 .unwrap_or_else(|| "—".to_string());
5813 let note = p
5814 .last_error
5815 .as_deref()
5816 .or(p.peer_did.as_deref())
5817 .unwrap_or("");
5818 println!(
5819 "{:<15} {:<8} {:<18} {:<10} {}",
5820 p.code, p.role, p.status, sas, note
5821 );
5822 }
5823 }
5824 Ok(())
5825}
5826
5827fn cmd_pair_list_watch(interval_secs: u64) -> Result<()> {
5839 use std::collections::HashMap;
5840 use std::io::Write;
5841 let interval = std::time::Duration::from_secs(interval_secs.max(1));
5842 let mut prev: HashMap<String, String> = HashMap::new();
5845 {
5846 let items = crate::pending_pair::list_pending()?;
5847 for p in &items {
5848 println!("{}", serde_json::to_string(&p)?);
5849 prev.insert(p.code.clone(), p.status.clone());
5850 }
5851 let _ = std::io::stdout().flush();
5853 }
5854 loop {
5855 std::thread::sleep(interval);
5856 let items = match crate::pending_pair::list_pending() {
5857 Ok(v) => v,
5858 Err(_) => continue,
5859 };
5860 let mut cur: HashMap<String, String> = HashMap::new();
5861 for p in &items {
5862 cur.insert(p.code.clone(), p.status.clone());
5863 match prev.get(&p.code) {
5864 None => {
5865 println!("{}", serde_json::to_string(&p)?);
5867 }
5868 Some(prev_status) if prev_status != &p.status => {
5869 println!("{}", serde_json::to_string(&p)?);
5871 }
5872 _ => {}
5873 }
5874 }
5875 for code in prev.keys() {
5876 if !cur.contains_key(code) {
5877 println!(
5880 "{}",
5881 serde_json::to_string(&json!({
5882 "code": code,
5883 "status": "removed",
5884 "_synthetic": true,
5885 }))?
5886 );
5887 }
5888 }
5889 let _ = std::io::stdout().flush();
5890 prev = cur;
5891 }
5892}
5893
5894fn cmd_pair_watch(
5898 code_phrase: &str,
5899 target_status: &str,
5900 timeout_secs: u64,
5901 as_json: bool,
5902) -> Result<()> {
5903 let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
5904 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
5905 let mut last_seen_status: Option<String> = None;
5906 loop {
5907 let p_opt = crate::pending_pair::read_pending(&code)?;
5908 let now = std::time::Instant::now();
5909 match p_opt {
5910 None => {
5911 if last_seen_status.is_some() {
5915 if as_json {
5916 println!(
5917 "{}",
5918 serde_json::to_string(&json!({"state": "finalized", "code": code}))?
5919 );
5920 } else {
5921 println!("pair {code} finalized (file removed)");
5922 }
5923 return Ok(());
5924 } else {
5925 if as_json {
5926 println!(
5927 "{}",
5928 serde_json::to_string(&json!({"error": "no such pair", "code": code}))?
5929 );
5930 }
5931 std::process::exit(1);
5932 }
5933 }
5934 Some(p) => {
5935 let cur = p.status.clone();
5936 if Some(cur.clone()) != last_seen_status {
5937 if as_json {
5938 println!("{}", serde_json::to_string(&p)?);
5940 }
5941 last_seen_status = Some(cur.clone());
5942 }
5943 if cur == target_status {
5944 if !as_json {
5945 let sas_str = p
5946 .sas
5947 .as_ref()
5948 .map(|s| format!("{}-{}", &s[..3], &s[3..]))
5949 .unwrap_or_else(|| "—".to_string());
5950 println!("pair {code} reached {target_status} (SAS: {sas_str})");
5951 }
5952 return Ok(());
5953 }
5954 if cur == "aborted" || cur == "aborted_restart" {
5955 if !as_json {
5956 let err = p.last_error.as_deref().unwrap_or("(no detail)");
5957 eprintln!("pair {code} {cur}: {err}");
5958 }
5959 std::process::exit(1);
5960 }
5961 }
5962 }
5963 if now >= deadline {
5964 if !as_json {
5965 eprintln!(
5966 "timeout after {timeout_secs}s waiting for pair {code} to reach {target_status}"
5967 );
5968 }
5969 std::process::exit(2);
5970 }
5971 std::thread::sleep(std::time::Duration::from_millis(250));
5972 }
5973}
5974
5975fn cmd_pair_cancel(code_phrase: &str, as_json: bool) -> Result<()> {
5976 let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
5977 let p = crate::pending_pair::read_pending(&code)?
5978 .ok_or_else(|| anyhow!("no pending pair for code {code}"))?;
5979 let client = crate::relay_client::RelayClient::new(&p.relay_url);
5980 let _ = client.pair_abandon(&p.code_hash);
5981 crate::pending_pair::delete_pending(&code)?;
5982 if as_json {
5983 println!(
5984 "{}",
5985 serde_json::to_string(&json!({
5986 "state": "cancelled",
5987 "code_phrase": code,
5988 }))?
5989 );
5990 } else {
5991 println!("cancelled pending pair {code} (relay slot released, file removed).");
5992 }
5993 Ok(())
5994}
5995
5996fn cmd_pair_abandon(code_phrase: &str, relay_url: &str) -> Result<()> {
5999 let code = crate::sas::parse_code_phrase(code_phrase)?;
6002 let code_hash = crate::pair_session::derive_code_hash(code);
6003 let client = crate::relay_client::RelayClient::new(relay_url);
6004 client.pair_abandon(&code_hash)?;
6005 println!("abandoned pair-slot for code {code_phrase} on {relay_url}");
6006 println!("host can now issue a fresh code; guest can re-join.");
6007 Ok(())
6008}
6009
6010fn cmd_invite(relay: &str, ttl: u64, uses: u32, share: bool, as_json: bool) -> Result<()> {
6013 let url = crate::pair_invite::mint_invite(Some(ttl), uses, Some(relay))?;
6014
6015 let share_payload: Option<Value> = if share {
6018 let client = reqwest::blocking::Client::new();
6019 let single_use = if uses == 1 { Some(1u32) } else { None };
6020 let body = json!({
6021 "invite_url": url,
6022 "ttl_seconds": ttl,
6023 "uses": single_use,
6024 });
6025 let endpoint = format!("{}/v1/invite/register", relay.trim_end_matches('/'));
6026 let resp = client.post(&endpoint).json(&body).send()?;
6027 if !resp.status().is_success() {
6028 let code = resp.status();
6029 let txt = resp.text().unwrap_or_default();
6030 bail!("relay {code} on /v1/invite/register: {txt}");
6031 }
6032 let parsed: Value = resp.json()?;
6033 let token = parsed
6034 .get("token")
6035 .and_then(Value::as_str)
6036 .ok_or_else(|| anyhow::anyhow!("relay reply missing token"))?
6037 .to_string();
6038 let share_url = format!("{}/i/{}", relay.trim_end_matches('/'), token);
6039 let curl_line = format!("curl -fsSL {share_url} | sh");
6040 Some(json!({
6041 "token": token,
6042 "share_url": share_url,
6043 "curl": curl_line,
6044 "expires_unix": parsed.get("expires_unix"),
6045 }))
6046 } else {
6047 None
6048 };
6049
6050 if as_json {
6051 let mut out = json!({
6052 "invite_url": url,
6053 "ttl_secs": ttl,
6054 "uses": uses,
6055 "relay": relay,
6056 });
6057 if let Some(s) = &share_payload {
6058 out["share"] = s.clone();
6059 }
6060 println!("{}", serde_json::to_string(&out)?);
6061 } else if let Some(s) = share_payload {
6062 let curl = s.get("curl").and_then(Value::as_str).unwrap_or("");
6063 eprintln!("# One-curl onboarding. Share this single line — installs wire if missing,");
6064 eprintln!("# accepts the invite, pairs both sides. TTL: {ttl}s. Uses: {uses}.");
6065 println!("{curl}");
6066 } else {
6067 eprintln!("# Share this URL with one peer. Pasting it = pair complete on their side.");
6068 eprintln!("# TTL: {ttl}s. Uses: {uses}.");
6069 println!("{url}");
6070 }
6071 Ok(())
6072}
6073
6074fn cmd_accept(url: &str, as_json: bool) -> Result<()> {
6075 let resolved = if url.starts_with("http://") || url.starts_with("https://") {
6079 let sep = if url.contains('?') { '&' } else { '?' };
6080 let resolve_url = format!("{url}{sep}format=url");
6081 let client = reqwest::blocking::Client::new();
6082 let resp = client
6083 .get(&resolve_url)
6084 .send()
6085 .with_context(|| format!("GET {resolve_url}"))?;
6086 if !resp.status().is_success() {
6087 bail!("could not resolve short URL {url} (HTTP {})", resp.status());
6088 }
6089 let body = resp.text().unwrap_or_default().trim().to_string();
6090 if !body.starts_with("wire://pair?") {
6091 bail!(
6092 "short URL {url} did not resolve to a wire:// invite. \
6093 (got: {}{})",
6094 body.chars().take(80).collect::<String>(),
6095 if body.chars().count() > 80 { "…" } else { "" }
6096 );
6097 }
6098 body
6099 } else {
6100 url.to_string()
6101 };
6102
6103 let result = crate::pair_invite::accept_invite(&resolved)?;
6104 if as_json {
6105 println!("{}", serde_json::to_string(&result)?);
6106 } else {
6107 let did = result
6108 .get("paired_with")
6109 .and_then(Value::as_str)
6110 .unwrap_or("?");
6111 println!("paired with {did}");
6112 println!(
6113 "you can now: wire send {} <kind> <body>",
6114 crate::agent_card::display_handle_from_did(did)
6115 );
6116 }
6117 Ok(())
6118}
6119
6120fn cmd_whois(handle: Option<&str>, as_json: bool, relay_override: Option<&str>) -> Result<()> {
6123 if let Some(h) = handle {
6124 let parsed = crate::pair_profile::parse_handle(h)?;
6125 if config::is_initialized()? {
6128 let card = config::read_agent_card()?;
6129 let local_handle = card
6130 .get("profile")
6131 .and_then(|p| p.get("handle"))
6132 .and_then(Value::as_str)
6133 .map(str::to_string);
6134 if local_handle.as_deref() == Some(h) {
6135 return cmd_whois(None, as_json, None);
6136 }
6137 }
6138 let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)?;
6140 if as_json {
6141 println!("{}", serde_json::to_string(&resolved)?);
6142 } else {
6143 print_resolved_profile(&resolved);
6144 }
6145 return Ok(());
6146 }
6147 let card = config::read_agent_card()?;
6148 if as_json {
6149 let profile = card.get("profile").cloned().unwrap_or(Value::Null);
6150 println!(
6151 "{}",
6152 serde_json::to_string(&json!({
6153 "did": card.get("did").cloned().unwrap_or(Value::Null),
6154 "profile": profile,
6155 }))?
6156 );
6157 } else {
6158 print!("{}", crate::pair_profile::render_self_summary()?);
6159 }
6160 Ok(())
6161}
6162
6163fn print_resolved_profile(resolved: &Value) {
6164 let did = resolved.get("did").and_then(Value::as_str).unwrap_or("?");
6165 let nick = resolved.get("nick").and_then(Value::as_str).unwrap_or("?");
6166 let relay = resolved
6167 .get("relay_url")
6168 .and_then(Value::as_str)
6169 .unwrap_or("");
6170 let slot = resolved
6171 .get("slot_id")
6172 .and_then(Value::as_str)
6173 .unwrap_or("");
6174 let profile = resolved
6175 .get("card")
6176 .and_then(|c| c.get("profile"))
6177 .cloned()
6178 .unwrap_or(Value::Null);
6179 println!("{did}");
6180 println!(" nick: {nick}");
6181 if !relay.is_empty() {
6182 println!(" relay_url: {relay}");
6183 }
6184 if !slot.is_empty() {
6185 println!(" slot_id: {slot}");
6186 }
6187 let pick =
6188 |k: &str| -> Option<String> { profile.get(k).and_then(Value::as_str).map(str::to_string) };
6189 if let Some(s) = pick("display_name") {
6190 println!(" display_name: {s}");
6191 }
6192 if let Some(s) = pick("emoji") {
6193 println!(" emoji: {s}");
6194 }
6195 if let Some(s) = pick("motto") {
6196 println!(" motto: {s}");
6197 }
6198 if let Some(arr) = profile.get("vibe").and_then(Value::as_array) {
6199 let joined: Vec<String> = arr
6200 .iter()
6201 .filter_map(|v| v.as_str().map(str::to_string))
6202 .collect();
6203 println!(" vibe: {}", joined.join(", "));
6204 }
6205 if let Some(s) = pick("pronouns") {
6206 println!(" pronouns: {s}");
6207 }
6208}
6209
6210fn host_of_url(url: &str) -> String {
6218 let no_scheme = url
6219 .trim_start_matches("https://")
6220 .trim_start_matches("http://");
6221 no_scheme
6222 .split('/')
6223 .next()
6224 .unwrap_or("")
6225 .split(':')
6226 .next()
6227 .unwrap_or("")
6228 .to_string()
6229}
6230
6231fn is_known_relay_domain(peer_domain: &str, our_relay_url: &str) -> bool {
6235 const KNOWN_GOOD: &[&str] = &["wireup.net", "wire.laulpogan.com"];
6237 let peer_domain = peer_domain.trim().to_ascii_lowercase();
6238 if KNOWN_GOOD.iter().any(|k| *k == peer_domain) {
6239 return true;
6240 }
6241 let our_host = host_of_url(our_relay_url).to_ascii_lowercase();
6244 if !our_host.is_empty() && our_host == peer_domain {
6245 return true;
6246 }
6247 false
6248}
6249
6250fn resolve_local_session<'a>(
6268 sessions: &'a [crate::session::SessionInfo],
6269 input: &str,
6270) -> Result<&'a crate::session::SessionInfo, ResolveError> {
6271 if let Some(s) = sessions.iter().find(|s| s.name == input) {
6274 return Ok(s);
6275 }
6276 let nick_matches: Vec<&crate::session::SessionInfo> = sessions
6277 .iter()
6278 .filter(|s| {
6279 s.character
6280 .as_ref()
6281 .map(|c| c.nickname == input)
6282 .unwrap_or(false)
6283 })
6284 .collect();
6285 match nick_matches.len() {
6286 0 => Err(ResolveError::NotFound),
6287 1 => Ok(nick_matches[0]),
6288 _ => Err(ResolveError::Ambiguous(
6289 nick_matches.iter().map(|s| s.name.clone()).collect(),
6290 )),
6291 }
6292}
6293
6294#[derive(Debug)]
6295enum ResolveError {
6296 NotFound,
6297 Ambiguous(Vec<String>),
6298}
6299
6300fn resolve_peer_handle(input: &str) -> Result<Option<String>, ResolveError> {
6316 let trust = match config::read_trust() {
6317 Ok(t) => t,
6318 Err(_) => return Ok(None),
6319 };
6320 let agents = match trust.get("agents").and_then(|a| a.as_object()) {
6321 Some(a) => a,
6322 None => return Ok(None),
6323 };
6324 if agents.contains_key(input) {
6325 return Ok(Some(input.to_string()));
6326 }
6327 let mut nick_matches: Vec<String> = Vec::new();
6328 for (handle, agent) in agents.iter() {
6329 let character = match agent.get("card") {
6333 Some(card) => crate::character::Character::from_card(card),
6334 None => match agent.get("did").and_then(Value::as_str) {
6335 Some(did) => crate::character::Character::from_did(did),
6336 None => continue,
6337 },
6338 };
6339 if character.nickname == input {
6340 nick_matches.push(handle.clone());
6341 }
6342 }
6343 match nick_matches.len() {
6344 0 => Ok(None),
6345 1 => Ok(Some(nick_matches.into_iter().next().unwrap())),
6346 _ => Err(ResolveError::Ambiguous(nick_matches)),
6347 }
6348}
6349
6350fn cmd_add_local_sister(sister_name: &str, as_json: bool) -> Result<()> {
6351 let sessions = crate::session::list_sessions()?;
6353 let sister = match resolve_local_session(&sessions, sister_name) {
6354 Ok(s) => s,
6355 Err(ResolveError::NotFound) => bail!(
6356 "no sister session named `{sister_name}` (matched by session name or character nickname). \
6357 Run `wire session list` to see what's available."
6358 ),
6359 Err(ResolveError::Ambiguous(candidates)) => bail!(
6360 "nickname `{sister_name}` is ambiguous — matches {} sessions: {}. \
6361 Disambiguate by passing the session name (one of those listed) instead of the nickname.",
6362 candidates.len(),
6363 candidates.join(", ")
6364 ),
6365 };
6366 if sister.name != sister_name {
6369 eprintln!(
6370 "wire add: resolved nickname `{sister_name}` → session `{}`",
6371 sister.name
6372 );
6373 }
6374
6375 let our_card = config::read_agent_card()
6378 .map_err(|_| anyhow!("not initialized — run `wire init <handle>` first"))?;
6379 let our_did = our_card
6380 .get("did")
6381 .and_then(Value::as_str)
6382 .ok_or_else(|| anyhow!("agent-card missing did"))?
6383 .to_string();
6384 if let Some(sister_did) = sister.did.as_deref()
6385 && sister_did == our_did
6386 {
6387 bail!("refusing to add self (`{sister_name}` is this very session)");
6388 }
6389
6390 let sister_card_path = sister
6392 .home_dir
6393 .join("config")
6394 .join("wire")
6395 .join("agent-card.json");
6396 let sister_card: Value = serde_json::from_slice(
6397 &std::fs::read(&sister_card_path)
6398 .with_context(|| format!("reading sister card {sister_card_path:?}"))?,
6399 )
6400 .with_context(|| format!("parsing sister card {sister_card_path:?}"))?;
6401 let sister_relay_state: Value = std::fs::read(
6402 sister
6403 .home_dir
6404 .join("config")
6405 .join("wire")
6406 .join("relay.json"),
6407 )
6408 .ok()
6409 .and_then(|b| serde_json::from_slice(&b).ok())
6410 .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
6411
6412 let sister_did = sister_card
6413 .get("did")
6414 .and_then(Value::as_str)
6415 .ok_or_else(|| anyhow!("sister card missing did"))?
6416 .to_string();
6417 let sister_handle = crate::agent_card::display_handle_from_did(&sister_did).to_string();
6418
6419 let sister_endpoints = crate::endpoints::self_endpoints(&sister_relay_state);
6423 if sister_endpoints.is_empty() {
6424 bail!(
6425 "sister `{sister_name}` has no endpoints in its relay.json — recreate with `wire session new --local-only` or `--with-local`"
6426 );
6427 }
6428 let sister_local = sister_endpoints
6429 .iter()
6430 .find(|e| e.scope == crate::endpoints::EndpointScope::Local);
6431 let delivery_endpoint = match sister_local {
6432 Some(e) => e.clone(),
6433 None => sister_endpoints[0].clone(),
6434 };
6435
6436 let our_relay_state = config::read_relay_state()?;
6442 let our_endpoints = crate::endpoints::self_endpoints(&our_relay_state);
6443 if our_endpoints.is_empty() {
6444 bail!(
6445 "this session has no endpoints — run `wire session new --local-only` or `wire bind-relay` first"
6446 );
6447 }
6448 let our_advertised = our_endpoints
6449 .iter()
6450 .find(|e| e.scope == crate::endpoints::EndpointScope::Federation)
6451 .cloned()
6452 .unwrap_or_else(|| our_endpoints[0].clone());
6453
6454 let mut trust = config::read_trust()?;
6458 crate::trust::add_agent_card_pin(&mut trust, &sister_card, Some("VERIFIED"));
6459 config::write_trust(&trust)?;
6460 let mut relay_state = config::read_relay_state()?;
6461 crate::endpoints::pin_peer_endpoints(&mut relay_state, &sister_handle, &sister_endpoints)?;
6462 config::write_relay_state(&relay_state)?;
6463
6464 let sk_seed = config::read_private_key()?;
6467 let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
6468 let pk_b64 = our_card
6469 .get("verify_keys")
6470 .and_then(Value::as_object)
6471 .and_then(|m| m.values().next())
6472 .and_then(|v| v.get("key"))
6473 .and_then(Value::as_str)
6474 .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
6475 let pk_bytes = crate::signing::b64decode(pk_b64)?;
6476 let now = time::OffsetDateTime::now_utc()
6477 .format(&time::format_description::well_known::Rfc3339)
6478 .unwrap_or_default();
6479 let mut body = json!({
6480 "card": our_card,
6481 "relay_url": our_advertised.relay_url,
6482 "slot_id": our_advertised.slot_id,
6483 "slot_token": our_advertised.slot_token,
6484 });
6485 body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
6486 let event = json!({
6487 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
6488 "timestamp": now,
6489 "from": our_did,
6490 "to": sister_did,
6491 "type": "pair_drop",
6492 "kind": 1100u32,
6493 "body": body,
6494 });
6495 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
6496 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
6497
6498 let client = crate::relay_client::RelayClient::new(&delivery_endpoint.relay_url);
6502 client
6503 .post_event(
6504 &delivery_endpoint.slot_id,
6505 &delivery_endpoint.slot_token,
6506 &signed,
6507 )
6508 .with_context(|| format!("delivering pair_drop to `{sister_name}`'s local slot"))?;
6509
6510 if as_json {
6511 println!(
6512 "{}",
6513 serde_json::to_string(&json!({
6514 "handle": sister_name,
6515 "paired_with": sister_did,
6516 "peer_handle": sister_handle,
6517 "event_id": event_id,
6518 "delivered_via": match delivery_endpoint.scope {
6519 crate::endpoints::EndpointScope::Local => "local",
6520 crate::endpoints::EndpointScope::Lan => "lan",
6521 crate::endpoints::EndpointScope::Uds => "uds",
6522 crate::endpoints::EndpointScope::Federation => "federation",
6523 },
6524 "status": "drop_sent",
6525 }))?
6526 );
6527 } else {
6528 let scope = match delivery_endpoint.scope {
6529 crate::endpoints::EndpointScope::Local => "local",
6530 crate::endpoints::EndpointScope::Lan => "lan",
6531 crate::endpoints::EndpointScope::Uds => "uds",
6532 crate::endpoints::EndpointScope::Federation => "federation",
6533 };
6534 println!(
6535 "→ 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.",
6536 delivery_endpoint.relay_url
6537 );
6538 }
6539 Ok(())
6540}
6541
6542fn cmd_add(
6543 handle_arg: &str,
6544 relay_override: Option<&str>,
6545 local_sister: bool,
6546 as_json: bool,
6547) -> Result<()> {
6548 if local_sister {
6556 let resolved = crate::session::resolve_local_sister(handle_arg)
6557 .unwrap_or_else(|| handle_arg.to_string());
6558 return cmd_add_local_sister(&resolved, as_json);
6559 }
6560 if !handle_arg.contains('@')
6561 && let Some(resolved) = crate::session::resolve_local_sister(handle_arg)
6562 {
6563 eprintln!(
6564 "wire add: `{handle_arg}` resolved to local sister session `{resolved}` \
6565 — routing via --local-sister (disk-read card, no relay lookup)."
6566 );
6567 return cmd_add_local_sister(&resolved, as_json);
6568 }
6569 if !handle_arg.contains('@') {
6570 bail!(
6571 "`{handle_arg}` doesn't match any local sister session and has no \
6572 @<relay> suffix for federation.\n\
6573 — Local sisters: `wire session list-local` (operator types name OR \
6574 character nickname)\n\
6575 — Federation: `wire add <handle>@<relay-domain>` (e.g. \
6576 `wire add alice@wireup.net`)"
6577 );
6578 }
6579 let parsed = crate::pair_profile::parse_handle(handle_arg)?;
6580
6581 let (our_did, our_relay, our_slot_id, our_slot_token) =
6583 crate::pair_invite::ensure_self_with_relay(relay_override)?;
6584 if our_did == format!("did:wire:{}", parsed.nick) {
6585 bail!("refusing to add self (handle matches own DID)");
6587 }
6588
6589 if let Some(pending) = crate::pending_inbound_pair::read_pending_inbound(&parsed.nick)? {
6599 return cmd_add_accept_pending(
6600 handle_arg,
6601 &parsed.nick,
6602 &pending,
6603 &our_relay,
6604 &our_slot_id,
6605 &our_slot_token,
6606 as_json,
6607 );
6608 }
6609
6610 if !is_known_relay_domain(&parsed.domain, &our_relay) {
6627 eprintln!(
6628 "wire add: WARN unfamiliar relay domain `{}`.",
6629 parsed.domain
6630 );
6631 eprintln!(
6632 " This is NOT `wireup.net` (the default), NOT your own relay (`{}`), ",
6633 host_of_url(&our_relay)
6634 );
6635 eprintln!(
6636 " and not on the known-good list. If you meant `{}@wireup.net`, ",
6637 parsed.nick
6638 );
6639 eprintln!(
6640 " run `wire add {}@wireup.net` instead. Otherwise verify with your",
6641 parsed.nick
6642 );
6643 eprintln!(" peer out-of-band that they actually run a relay at this domain");
6644 eprintln!(" before relying on the pair. (See issue #9.4.)");
6645 }
6646
6647 let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)?;
6649 let peer_card = resolved
6650 .get("card")
6651 .cloned()
6652 .ok_or_else(|| anyhow!("resolved missing card"))?;
6653 let peer_did = resolved
6654 .get("did")
6655 .and_then(Value::as_str)
6656 .ok_or_else(|| anyhow!("resolved missing did"))?
6657 .to_string();
6658 let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
6659 let peer_slot_id = resolved
6660 .get("slot_id")
6661 .and_then(Value::as_str)
6662 .ok_or_else(|| anyhow!("resolved missing slot_id"))?
6663 .to_string();
6664 let peer_relay = resolved
6665 .get("relay_url")
6666 .and_then(Value::as_str)
6667 .map(str::to_string)
6668 .or_else(|| relay_override.map(str::to_string))
6669 .unwrap_or_else(|| format!("https://{}", parsed.domain));
6670
6671 let mut trust = config::read_trust()?;
6673 crate::trust::add_agent_card_pin(&mut trust, &peer_card, Some("VERIFIED"));
6674 config::write_trust(&trust)?;
6675 let mut relay_state = config::read_relay_state()?;
6676 let mut endpoints: Vec<crate::endpoints::Endpoint> = relay_state
6689 .get("peers")
6690 .and_then(|p| p.get(&peer_handle))
6691 .and_then(|e| e.get("endpoints"))
6692 .and_then(|a| serde_json::from_value::<Vec<crate::endpoints::Endpoint>>(a.clone()).ok())
6693 .unwrap_or_default();
6694 let fed_token = endpoints
6695 .iter()
6696 .find(|e| {
6697 e.relay_url == peer_relay && e.scope == crate::endpoints::EndpointScope::Federation
6698 })
6699 .map(|e| e.slot_token.clone())
6700 .unwrap_or_default();
6701 let fed_ep = crate::endpoints::Endpoint {
6702 relay_url: peer_relay.clone(),
6703 slot_id: peer_slot_id.clone(),
6704 slot_token: fed_token, scope: crate::endpoints::EndpointScope::Federation,
6706 };
6707 if let Some(existing) = endpoints
6708 .iter_mut()
6709 .find(|e| e.relay_url == fed_ep.relay_url)
6710 {
6711 *existing = fed_ep;
6712 } else {
6713 endpoints.push(fed_ep);
6714 }
6715 crate::endpoints::pin_peer_endpoints(&mut relay_state, &peer_handle, &endpoints)?;
6716 config::write_relay_state(&relay_state)?;
6717
6718 let our_card = config::read_agent_card()?;
6721 let sk_seed = config::read_private_key()?;
6722 let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
6723 let pk_b64 = our_card
6724 .get("verify_keys")
6725 .and_then(Value::as_object)
6726 .and_then(|m| m.values().next())
6727 .and_then(|v| v.get("key"))
6728 .and_then(Value::as_str)
6729 .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
6730 let pk_bytes = crate::signing::b64decode(pk_b64)?;
6731 let now = time::OffsetDateTime::now_utc()
6732 .format(&time::format_description::well_known::Rfc3339)
6733 .unwrap_or_default();
6734 let our_relay_state = config::read_relay_state().unwrap_or_else(|_| json!({}));
6739 let our_endpoints = crate::endpoints::self_endpoints(&our_relay_state);
6740 let mut body = json!({
6741 "card": our_card,
6742 "relay_url": our_relay,
6743 "slot_id": our_slot_id,
6744 "slot_token": our_slot_token,
6745 });
6746 if !our_endpoints.is_empty() {
6747 body["endpoints"] = serde_json::to_value(&our_endpoints).unwrap_or(json!([]));
6748 }
6749 let event = json!({
6750 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
6751 "timestamp": now,
6752 "from": our_did,
6753 "to": peer_did,
6754 "type": "pair_drop",
6755 "kind": 1100u32,
6756 "body": body,
6757 });
6758 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
6759
6760 let client = crate::relay_client::RelayClient::new(&peer_relay);
6762 let resp = client.handle_intro(&parsed.nick, &signed)?;
6763 let event_id = signed
6764 .get("event_id")
6765 .and_then(Value::as_str)
6766 .unwrap_or("")
6767 .to_string();
6768
6769 if as_json {
6770 println!(
6771 "{}",
6772 serde_json::to_string(&json!({
6773 "handle": handle_arg,
6774 "paired_with": peer_did,
6775 "peer_handle": peer_handle,
6776 "event_id": event_id,
6777 "drop_response": resp,
6778 "status": "drop_sent",
6779 }))?
6780 );
6781 } else {
6782 println!(
6783 "→ 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."
6784 );
6785 }
6786 Ok(())
6787}
6788
6789fn cmd_add_accept_pending(
6796 handle_arg: &str,
6797 peer_nick: &str,
6798 pending: &crate::pending_inbound_pair::PendingInboundPair,
6799 _our_relay: &str,
6800 _our_slot_id: &str,
6801 _our_slot_token: &str,
6802 as_json: bool,
6803) -> Result<()> {
6804 let mut trust = config::read_trust()?;
6807 crate::trust::add_agent_card_pin(&mut trust, &pending.peer_card, Some("VERIFIED"));
6808 config::write_trust(&trust)?;
6809
6810 let mut relay_state = config::read_relay_state()?;
6816 let endpoints_to_pin = if pending.peer_endpoints.is_empty() {
6817 vec![crate::endpoints::Endpoint::federation(
6818 pending.peer_relay_url.clone(),
6819 pending.peer_slot_id.clone(),
6820 pending.peer_slot_token.clone(),
6821 )]
6822 } else {
6823 pending.peer_endpoints.clone()
6824 };
6825 crate::endpoints::pin_peer_endpoints(
6826 &mut relay_state,
6827 &pending.peer_handle,
6828 &endpoints_to_pin,
6829 )?;
6830 config::write_relay_state(&relay_state)?;
6831
6832 crate::pair_invite::send_pair_drop_ack(
6834 &pending.peer_handle,
6835 &pending.peer_relay_url,
6836 &pending.peer_slot_id,
6837 &pending.peer_slot_token,
6838 )
6839 .with_context(|| {
6840 format!(
6841 "pair_drop_ack send to {} @ {} slot {} failed",
6842 pending.peer_handle, pending.peer_relay_url, pending.peer_slot_id
6843 )
6844 })?;
6845
6846 crate::pending_inbound_pair::consume_pending_inbound(peer_nick)?;
6848
6849 if as_json {
6850 println!(
6851 "{}",
6852 serde_json::to_string(&json!({
6853 "handle": handle_arg,
6854 "paired_with": pending.peer_did,
6855 "peer_handle": pending.peer_handle,
6856 "status": "bilateral_accepted",
6857 "via": "pending_inbound",
6858 }))?
6859 );
6860 } else {
6861 println!(
6862 "→ 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} \"...\"`.",
6863 peer = pending.peer_handle,
6864 );
6865 }
6866 Ok(())
6867}
6868
6869fn cmd_pair_accept(peer_nick: &str, as_json: bool) -> Result<()> {
6876 let nick = crate::agent_card::bare_handle(peer_nick);
6877 let pending = crate::pending_inbound_pair::read_pending_inbound(nick)?.ok_or_else(|| {
6878 anyhow!(
6879 "no pending pair request from {nick}. Run `wire pair-list-inbound` to see who is waiting, \
6880 or use `wire add <peer>@<relay>` to send a fresh outbound pair request."
6881 )
6882 })?;
6883 let (_our_did, our_relay, our_slot_id, our_slot_token) =
6884 crate::pair_invite::ensure_self_with_relay(None)?;
6885 let handle_arg = format!("{}@{}", pending.peer_handle, pending.peer_relay_url);
6886 cmd_add_accept_pending(
6887 &handle_arg,
6888 nick,
6889 &pending,
6890 &our_relay,
6891 &our_slot_id,
6892 &our_slot_token,
6893 as_json,
6894 )
6895}
6896
6897fn cmd_pair_list_inbound(as_json: bool) -> Result<()> {
6900 let items = crate::pending_inbound_pair::list_pending_inbound()?;
6901 if as_json {
6902 println!("{}", serde_json::to_string(&items)?);
6903 return Ok(());
6904 }
6905 if items.is_empty() {
6906 println!("no pending pair requests — your inbox is clear.");
6907 return Ok(());
6908 }
6909 let plural = if items.len() == 1 { "" } else { "s" };
6916 println!("{} pending pair request{plural}:\n", items.len());
6917 for p in &items {
6918 let ch = crate::character::Character::from_did(&p.peer_did);
6919 let glyph = crate::character::emoji_with_fallback(&ch);
6920 println!(
6923 " {glyph} {nick} ({handle}) wants to pair with you",
6924 nick = ch.nickname,
6925 handle = p.peer_handle,
6926 );
6927 }
6928 println!();
6929 println!(
6930 "→ to accept any: `wire accept <name>` (e.g. `wire accept {first}`)",
6931 first = items
6932 .first()
6933 .map(|p| {
6934 let ch = crate::character::Character::from_did(&p.peer_did);
6935 ch.nickname
6936 })
6937 .unwrap_or_else(|| "<name>".to_string())
6938 );
6939 println!("→ to refuse: `wire reject <name>`");
6940 Ok(())
6941}
6942
6943fn cmd_pair_reject(peer_nick: &str, as_json: bool) -> Result<()> {
6947 let nick = crate::agent_card::bare_handle(peer_nick);
6948 let existed = crate::pending_inbound_pair::read_pending_inbound(nick)?;
6949 crate::pending_inbound_pair::consume_pending_inbound(nick)?;
6950
6951 if as_json {
6952 println!(
6953 "{}",
6954 serde_json::to_string(&json!({
6955 "peer": nick,
6956 "rejected": existed.is_some(),
6957 "had_pending": existed.is_some(),
6958 }))?
6959 );
6960 } else if existed.is_some() {
6961 println!(
6962 "→ rejected pending pair from {nick}\n→ pending-inbound record deleted; no ack sent."
6963 );
6964 } else {
6965 println!("no pending pair from {nick} — nothing to reject");
6966 }
6967 Ok(())
6968}
6969
6970fn cmd_mesh(cmd: MeshCommand) -> Result<()> {
6981 match cmd {
6982 MeshCommand::Status { stale_secs, json } => cmd_session_mesh_status(stale_secs, json),
6983 MeshCommand::Broadcast {
6984 kind,
6985 scope,
6986 exclude,
6987 noreply,
6988 body,
6989 json,
6990 } => cmd_mesh_broadcast(&kind, &scope, &exclude, noreply, &body, json),
6991 MeshCommand::Role { action } => cmd_mesh_role(action),
6992 MeshCommand::Route {
6993 role,
6994 strategy,
6995 exclude,
6996 kind,
6997 body,
6998 json,
6999 } => cmd_mesh_route(&role, &strategy, &exclude, &kind, &body, json),
7000 }
7001}
7002
7003fn cmd_mesh_route(
7008 role: &str,
7009 strategy: &str,
7010 exclude: &[String],
7011 kind: &str,
7012 body_arg: &str,
7013 as_json: bool,
7014) -> Result<()> {
7015 use std::time::Instant;
7016
7017 if !config::is_initialized()? {
7018 bail!("not initialized — run `wire init <handle>` first");
7019 }
7020 let strategy = strategy.to_ascii_lowercase();
7021 if !matches!(strategy.as_str(), "round-robin" | "first" | "random") {
7022 bail!("unknown strategy `{strategy}` — use round-robin | first | random");
7023 }
7024
7025 let state = config::read_relay_state()?;
7028 let pinned: std::collections::BTreeSet<String> = state["peers"]
7029 .as_object()
7030 .map(|m| m.keys().cloned().collect())
7031 .unwrap_or_default();
7032
7033 let exclude_set: std::collections::HashSet<&str> = exclude.iter().map(String::as_str).collect();
7034
7035 let sessions = crate::session::list_sessions()?;
7040 let mut candidates: Vec<(String, Option<String>)> = Vec::new(); for s in &sessions {
7042 let handle = match s.handle.as_ref() {
7043 Some(h) => h.clone(),
7044 None => continue,
7045 };
7046 if exclude_set.contains(handle.as_str()) {
7047 continue;
7048 }
7049 if !pinned.contains(&handle) {
7050 continue;
7051 }
7052 let card_path = s
7053 .home_dir
7054 .join("config")
7055 .join("wire")
7056 .join("agent-card.json");
7057 let card_role = std::fs::read(&card_path)
7058 .ok()
7059 .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
7060 .and_then(|c| {
7061 c.get("profile")
7062 .and_then(|p| p.get("role"))
7063 .and_then(Value::as_str)
7064 .map(str::to_string)
7065 });
7066 if card_role.as_deref() == Some(role) {
7067 candidates.push((handle, s.did.clone()));
7068 }
7069 }
7070
7071 candidates.sort_by(|a, b| a.0.cmp(&b.0));
7072 candidates.dedup_by(|a, b| a.0 == b.0);
7073
7074 if candidates.is_empty() {
7075 bail!(
7076 "no pinned sister with role=`{role}` (run `wire mesh role list` to see what's available)"
7077 );
7078 }
7079
7080 let chosen = match strategy.as_str() {
7081 "first" => candidates[0].clone(),
7082 "random" => {
7083 use rand::Rng;
7084 let idx = rand::thread_rng().gen_range(0..candidates.len());
7085 candidates[idx].clone()
7086 }
7087 "round-robin" => {
7088 let cursor_path = mesh_route_cursor_path()?;
7093 let mut cursors: std::collections::BTreeMap<String, String> =
7094 read_mesh_route_cursors(&cursor_path);
7095 let last = cursors.get(role).cloned();
7096 let pick = match last {
7097 None => candidates[0].clone(),
7098 Some(last_h) => candidates
7099 .iter()
7100 .find(|(h, _)| h.as_str() > last_h.as_str())
7101 .cloned()
7102 .unwrap_or_else(|| candidates[0].clone()),
7103 };
7104 cursors.insert(role.to_string(), pick.0.clone());
7105 write_mesh_route_cursors(&cursor_path, &cursors)?;
7106 pick
7107 }
7108 _ => unreachable!(),
7109 };
7110
7111 let (chosen_handle, _chosen_did) = chosen;
7112
7113 let body_value: Value = if body_arg == "-" {
7115 use std::io::Read;
7116 let mut raw = String::new();
7117 std::io::stdin()
7118 .read_to_string(&mut raw)
7119 .with_context(|| "reading body from stdin")?;
7120 serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
7121 } else if let Some(path) = body_arg.strip_prefix('@') {
7122 let raw =
7123 std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
7124 serde_json::from_str(&raw).unwrap_or(Value::String(raw))
7125 } else {
7126 Value::String(body_arg.to_string())
7127 };
7128
7129 let sk_seed = config::read_private_key()?;
7130 let card = config::read_agent_card()?;
7131 let did = card
7132 .get("did")
7133 .and_then(Value::as_str)
7134 .ok_or_else(|| anyhow!("agent-card missing did"))?
7135 .to_string();
7136 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
7137 let pk_b64 = card
7138 .get("verify_keys")
7139 .and_then(Value::as_object)
7140 .and_then(|m| m.values().next())
7141 .and_then(|v| v.get("key"))
7142 .and_then(Value::as_str)
7143 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
7144 let pk_bytes = crate::signing::b64decode(pk_b64)?;
7145
7146 let kind_id = parse_kind(kind)?;
7147 let now_iso = time::OffsetDateTime::now_utc()
7148 .format(&time::format_description::well_known::Rfc3339)
7149 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
7150
7151 let event = json!({
7152 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
7153 "timestamp": now_iso,
7154 "from": did,
7155 "to": format!("did:wire:{chosen_handle}"),
7156 "type": kind,
7157 "kind": kind_id,
7158 "body": json!({
7159 "content": body_value,
7160 "routed_via": {
7161 "role": role,
7162 "strategy": strategy,
7163 },
7164 }),
7165 });
7166 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)
7167 .map_err(|e| anyhow!("sign_message_v31 failed: {e:?}"))?;
7168 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
7169
7170 let line = serde_json::to_vec(&signed)?;
7171 config::append_outbox_record(&chosen_handle, &line)?;
7172
7173 let endpoints = crate::endpoints::peer_endpoints_in_priority_order(&state, &chosen_handle);
7174 if endpoints.is_empty() {
7175 bail!(
7176 "no reachable endpoint pinned for `{chosen_handle}` (the role matched, but we can't push)"
7177 );
7178 }
7179 let start = Instant::now();
7180 let mut delivered = false;
7181 let mut last_err: Option<String> = None;
7182 let mut via_scope: Option<String> = None;
7183 for ep in &endpoints {
7184 match crate::relay_client::post_event_to_endpoint(ep, &signed) {
7189 Ok(_) => {
7190 delivered = true;
7191 via_scope = Some(
7192 match ep.scope {
7193 crate::endpoints::EndpointScope::Local => "local",
7194 crate::endpoints::EndpointScope::Lan => "lan",
7195 crate::endpoints::EndpointScope::Uds => "uds",
7196 crate::endpoints::EndpointScope::Federation => "federation",
7197 }
7198 .to_string(),
7199 );
7200 break;
7201 }
7202 Err(e) => last_err = Some(format!("{e:#}")),
7203 }
7204 }
7205 let rtt_ms = start.elapsed().as_millis() as u64;
7206
7207 let summary = json!({
7208 "role": role,
7209 "strategy": strategy,
7210 "routed_to": chosen_handle,
7211 "event_id": event_id,
7212 "delivered": delivered,
7213 "delivered_via": via_scope,
7214 "rtt_ms": rtt_ms,
7215 "candidates": candidates.iter().map(|(h, _)| h.clone()).collect::<Vec<_>>(),
7216 "error": last_err,
7217 });
7218
7219 if as_json {
7220 println!("{}", serde_json::to_string(&summary)?);
7221 } else if delivered {
7222 let via = via_scope.as_deref().unwrap_or("?");
7223 println!("wire mesh route: {role} → {chosen_handle} ({rtt_ms}ms, {via})");
7224 } else {
7225 let err = last_err.as_deref().unwrap_or("no endpoints reachable");
7226 bail!("delivery to `{chosen_handle}` failed: {err}");
7227 }
7228 Ok(())
7229}
7230
7231fn mesh_route_cursor_path() -> Result<std::path::PathBuf> {
7232 Ok(config::state_dir()?.join("mesh-route-cursor.json"))
7233}
7234
7235fn read_mesh_route_cursors(path: &std::path::Path) -> std::collections::BTreeMap<String, String> {
7236 std::fs::read(path)
7237 .ok()
7238 .and_then(|b| serde_json::from_slice(&b).ok())
7239 .unwrap_or_default()
7240}
7241
7242fn write_mesh_route_cursors(
7243 path: &std::path::Path,
7244 cursors: &std::collections::BTreeMap<String, String>,
7245) -> Result<()> {
7246 if let Some(parent) = path.parent() {
7247 std::fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
7248 }
7249 let body = serde_json::to_vec_pretty(cursors)?;
7250 std::fs::write(path, body).with_context(|| format!("writing {path:?}"))?;
7251 Ok(())
7252}
7253
7254fn cmd_mesh_role(action: MeshRoleAction) -> Result<()> {
7259 match action {
7260 MeshRoleAction::Set { role, json } => {
7261 validate_role_tag(&role)?;
7262 let new_profile =
7263 crate::pair_profile::write_profile_field("role", Value::String(role.clone()))?;
7264 if json {
7265 println!(
7266 "{}",
7267 serde_json::to_string(&json!({
7268 "role": role,
7269 "profile": new_profile,
7270 }))?
7271 );
7272 } else {
7273 println!("self role = {role} (signed into agent-card)");
7274 }
7275 }
7276 MeshRoleAction::Get { peer, json } => {
7277 let (who, role) = match peer.as_deref() {
7278 None => {
7279 let card = config::read_agent_card()?;
7280 let role = card
7281 .get("profile")
7282 .and_then(|p| p.get("role"))
7283 .and_then(Value::as_str)
7284 .map(str::to_string);
7285 let who = card
7286 .get("did")
7287 .and_then(Value::as_str)
7288 .map(|d| crate::agent_card::display_handle_from_did(d).to_string())
7289 .unwrap_or_else(|| "self".to_string());
7290 (who, role)
7291 }
7292 Some(handle) => {
7293 let bare = crate::agent_card::bare_handle(handle).to_string();
7294 let trust = config::read_trust()?;
7295 let role = trust
7296 .get("agents")
7297 .and_then(|a| a.get(&bare))
7298 .and_then(|a| a.get("card"))
7299 .and_then(|c| c.get("profile"))
7300 .and_then(|p| p.get("role"))
7301 .and_then(Value::as_str)
7302 .map(str::to_string);
7303 (bare, role)
7304 }
7305 };
7306 if json {
7307 println!(
7308 "{}",
7309 serde_json::to_string(&json!({
7310 "handle": who,
7311 "role": role,
7312 }))?
7313 );
7314 } else {
7315 match role {
7316 Some(r) => println!("{who}: {r}"),
7317 None => println!("{who}: (unset)"),
7318 }
7319 }
7320 }
7321 MeshRoleAction::List { json } => {
7322 let mut self_did: Option<String> = None;
7323 if let Ok(card) = config::read_agent_card() {
7324 self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
7325 }
7326 let sessions = crate::session::list_sessions()?;
7327 let mut rows: Vec<Value> = Vec::new();
7328 for s in &sessions {
7329 let card_path = s
7330 .home_dir
7331 .join("config")
7332 .join("wire")
7333 .join("agent-card.json");
7334 let role = std::fs::read(&card_path)
7335 .ok()
7336 .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
7337 .and_then(|c| {
7338 c.get("profile")
7339 .and_then(|p| p.get("role"))
7340 .and_then(Value::as_str)
7341 .map(str::to_string)
7342 });
7343 let is_self = match (&self_did, &s.did) {
7344 (Some(a), Some(b)) => a == b,
7345 _ => false,
7346 };
7347 rows.push(json!({
7348 "name": s.name,
7349 "handle": s.handle,
7350 "role": role,
7351 "self": is_self,
7352 }));
7353 }
7354 rows.sort_by(|a, b| {
7355 a["name"]
7356 .as_str()
7357 .unwrap_or("")
7358 .cmp(b["name"].as_str().unwrap_or(""))
7359 });
7360 if json {
7361 println!("{}", serde_json::to_string(&json!({"sessions": rows}))?);
7362 } else if rows.is_empty() {
7363 println!("no sister sessions on this machine.");
7364 } else {
7365 println!("SISTER ROLES (this machine):");
7366 for r in &rows {
7367 let name = r["name"].as_str().unwrap_or("?");
7368 let role = r["role"].as_str().unwrap_or("(unset)");
7369 let marker = if r["self"].as_bool().unwrap_or(false) {
7370 " ← you"
7371 } else {
7372 ""
7373 };
7374 println!(" {name:<24} {role}{marker}");
7375 }
7376 }
7377 }
7378 MeshRoleAction::Clear { json } => {
7379 let new_profile = crate::pair_profile::write_profile_field("role", Value::Null)?;
7380 if json {
7381 println!(
7382 "{}",
7383 serde_json::to_string(&json!({
7384 "cleared": true,
7385 "profile": new_profile,
7386 }))?
7387 );
7388 } else {
7389 println!("self role cleared");
7390 }
7391 }
7392 }
7393 Ok(())
7394}
7395
7396fn validate_role_tag(role: &str) -> Result<()> {
7401 if role.is_empty() {
7402 bail!("role must not be empty (use `wire mesh role --clear` to unset)");
7403 }
7404 if role.len() > 32 {
7405 bail!("role too long ({} chars; max 32)", role.len());
7406 }
7407 for c in role.chars() {
7408 if !(c.is_ascii_alphanumeric() || c == '-' || c == '_') {
7409 bail!("role contains illegal char {c:?} (allowed: A-Z a-z 0-9 - _)");
7410 }
7411 }
7412 Ok(())
7413}
7414
7415fn cmd_mesh_broadcast(
7435 kind: &str,
7436 scope_str: &str,
7437 exclude: &[String],
7438 _noreply: bool,
7439 body_arg: &str,
7440 as_json: bool,
7441) -> Result<()> {
7442 use std::time::Instant;
7443
7444 if !config::is_initialized()? {
7445 bail!("not initialized — run `wire init <handle>` first");
7446 }
7447
7448 let scope = match scope_str {
7449 "local" => crate::endpoints::EndpointScope::Local,
7450 "federation" => crate::endpoints::EndpointScope::Federation,
7451 "both" => {
7452 crate::endpoints::EndpointScope::Local
7456 }
7457 other => bail!("unknown scope `{other}` — use local | federation | both"),
7458 };
7459 let any_scope = scope_str == "both";
7460
7461 let state = config::read_relay_state()?;
7462 let peers = state["peers"].as_object().cloned().unwrap_or_default();
7463 if peers.is_empty() {
7464 bail!("no peers pinned — run `wire accept <invite-url>` or `wire pair-accept` first");
7465 }
7466
7467 let exclude_set: std::collections::HashSet<&str> = exclude.iter().map(String::as_str).collect();
7468
7469 struct Target {
7473 handle: String,
7474 endpoints: Vec<crate::endpoints::Endpoint>,
7475 }
7476 let mut targets: Vec<Target> = Vec::new();
7477 let mut skipped_wrong_scope: Vec<String> = Vec::new();
7478 let mut skipped_excluded: Vec<String> = Vec::new();
7479 for handle in peers.keys() {
7480 if exclude_set.contains(handle.as_str()) {
7481 skipped_excluded.push(handle.clone());
7482 continue;
7483 }
7484 let ordered = crate::endpoints::peer_endpoints_in_priority_order(&state, handle);
7485 let filtered: Vec<crate::endpoints::Endpoint> = ordered
7486 .into_iter()
7487 .filter(|ep| any_scope || ep.scope == scope)
7488 .collect();
7489 if filtered.is_empty() {
7490 skipped_wrong_scope.push(handle.clone());
7491 continue;
7492 }
7493 targets.push(Target {
7494 handle: handle.clone(),
7495 endpoints: filtered,
7496 });
7497 }
7498
7499 if targets.is_empty() {
7500 bail!(
7501 "no peers matched scope=`{scope_str}` after exclude filter ({} excluded, {} wrong-scope)",
7502 skipped_excluded.len(),
7503 skipped_wrong_scope.len()
7504 );
7505 }
7506
7507 let sk_seed = config::read_private_key()?;
7509 let card = config::read_agent_card()?;
7510 let did = card
7511 .get("did")
7512 .and_then(Value::as_str)
7513 .ok_or_else(|| anyhow!("agent-card missing did"))?
7514 .to_string();
7515 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
7516 let pk_b64 = card
7517 .get("verify_keys")
7518 .and_then(Value::as_object)
7519 .and_then(|m| m.values().next())
7520 .and_then(|v| v.get("key"))
7521 .and_then(Value::as_str)
7522 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
7523 let pk_bytes = crate::signing::b64decode(pk_b64)?;
7524
7525 let body_value: Value = if body_arg == "-" {
7526 use std::io::Read;
7527 let mut raw = String::new();
7528 std::io::stdin()
7529 .read_to_string(&mut raw)
7530 .with_context(|| "reading body from stdin")?;
7531 serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
7532 } else if let Some(path) = body_arg.strip_prefix('@') {
7533 let raw =
7534 std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
7535 serde_json::from_str(&raw).unwrap_or(Value::String(raw))
7536 } else {
7537 Value::String(body_arg.to_string())
7538 };
7539
7540 let kind_id = parse_kind(kind)?;
7541 let now_iso = time::OffsetDateTime::now_utc()
7542 .format(&time::format_description::well_known::Rfc3339)
7543 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
7544
7545 let broadcast_id = generate_broadcast_id();
7546 let target_count = targets.len();
7547
7548 let mut signed_per_peer: Vec<(String, Vec<crate::endpoints::Endpoint>, Value, String)> =
7552 Vec::with_capacity(targets.len());
7553 for t in &targets {
7554 let body = json!({
7555 "content": body_value,
7556 "broadcast_id": broadcast_id,
7557 "broadcast_target_count": target_count,
7558 });
7559 let event = json!({
7560 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
7561 "timestamp": now_iso,
7562 "from": did,
7563 "to": format!("did:wire:{}", t.handle),
7564 "type": kind,
7565 "kind": kind_id,
7566 "body": body,
7567 });
7568 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)
7569 .map_err(|e| anyhow!("sign_message_v31 failed for `{}`: {e:?}", t.handle))?;
7570 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
7571 signed_per_peer.push((t.handle.clone(), t.endpoints.clone(), signed, event_id));
7572 }
7573
7574 for (peer, _, signed, _) in &signed_per_peer {
7578 let line = serde_json::to_vec(signed)?;
7579 config::append_outbox_record(peer, &line)?;
7580 }
7581
7582 use std::sync::mpsc;
7586 let (tx, rx) = mpsc::channel::<Value>();
7587 std::thread::scope(|s| {
7588 for (peer, endpoints, signed, event_id) in &signed_per_peer {
7589 let tx = tx.clone();
7590 let peer = peer.clone();
7591 let event_id = event_id.clone();
7592 let endpoints = endpoints.clone();
7593 let signed = signed.clone();
7594 s.spawn(move || {
7595 let start = Instant::now();
7596 let mut delivered = false;
7597 let mut last_err: Option<String> = None;
7598 let mut delivered_via: Option<String> = None;
7599 for ep in &endpoints {
7600 match crate::relay_client::post_event_to_endpoint(ep, &signed) {
7605 Ok(_) => {
7606 delivered = true;
7607 delivered_via = Some(
7608 match ep.scope {
7609 crate::endpoints::EndpointScope::Local => "local",
7610 crate::endpoints::EndpointScope::Lan => "lan",
7611 crate::endpoints::EndpointScope::Uds => "uds",
7612 crate::endpoints::EndpointScope::Federation => "federation",
7613 }
7614 .to_string(),
7615 );
7616 break;
7617 }
7618 Err(e) => last_err = Some(format!("{e:#}")),
7619 }
7620 }
7621 let rtt_ms = start.elapsed().as_millis() as u64;
7622 let _ = tx.send(json!({
7623 "peer": peer,
7624 "event_id": event_id,
7625 "delivered": delivered,
7626 "delivered_via": delivered_via,
7627 "rtt_ms": rtt_ms,
7628 "error": last_err,
7629 }));
7630 });
7631 }
7632 });
7633 drop(tx);
7634
7635 let mut results: Vec<Value> = rx.iter().collect();
7636 results.sort_by(|a, b| {
7637 a["peer"]
7638 .as_str()
7639 .unwrap_or("")
7640 .cmp(b["peer"].as_str().unwrap_or(""))
7641 });
7642
7643 let delivered = results
7644 .iter()
7645 .filter(|r| r["delivered"].as_bool().unwrap_or(false))
7646 .count();
7647 let failed = results.len() - delivered;
7648
7649 let summary = json!({
7650 "broadcast_id": broadcast_id,
7651 "kind": kind,
7652 "scope": scope_str,
7653 "target_count": target_count,
7654 "delivered": delivered,
7655 "failed": failed,
7656 "skipped_excluded": skipped_excluded,
7657 "skipped_wrong_scope": skipped_wrong_scope,
7658 "results": results,
7659 });
7660
7661 if as_json {
7662 println!("{}", serde_json::to_string(&summary)?);
7663 return Ok(());
7664 }
7665
7666 println!("wire mesh broadcast: scope={scope_str} → {target_count} pinned peer(s)");
7667 for r in &results {
7668 let peer = r["peer"].as_str().unwrap_or("?");
7669 let delivered = r["delivered"].as_bool().unwrap_or(false);
7670 let rtt = r["rtt_ms"].as_u64().unwrap_or(0);
7671 let via = r["delivered_via"].as_str().unwrap_or("");
7672 if delivered {
7673 println!(" {peer:<24} ✓ delivered ({rtt}ms, {via})");
7674 } else {
7675 let err = r["error"].as_str().unwrap_or("?");
7676 println!(" {peer:<24} ✗ failed — {err}");
7677 }
7678 }
7679 if !skipped_excluded.is_empty() {
7680 println!(" excluded: {}", skipped_excluded.join(", "));
7681 }
7682 if !skipped_wrong_scope.is_empty() {
7683 println!(
7684 " skipped (wrong scope): {}",
7685 skipped_wrong_scope.join(", ")
7686 );
7687 }
7688 println!("broadcast_id: {broadcast_id}");
7689 Ok(())
7690}
7691
7692fn generate_broadcast_id() -> String {
7696 use rand::RngCore;
7697 let mut buf = [0u8; 16];
7698 rand::thread_rng().fill_bytes(&mut buf);
7699 let h = hex::encode(buf);
7700 format!(
7701 "{}-{}-{}-{}-{}",
7702 &h[0..8],
7703 &h[8..12],
7704 &h[12..16],
7705 &h[16..20],
7706 &h[20..32],
7707 )
7708}
7709
7710fn cmd_session(cmd: SessionCommand) -> Result<()> {
7711 match cmd {
7712 SessionCommand::New {
7713 name,
7714 relay,
7715 with_local,
7716 local_relay,
7717 with_lan,
7718 lan_relay,
7719 with_uds,
7720 uds_socket,
7721 no_daemon,
7722 local_only,
7723 json,
7724 } => cmd_session_new(
7725 name.as_deref(),
7726 &relay,
7727 with_local,
7728 &local_relay,
7729 with_lan,
7730 lan_relay.as_deref(),
7731 with_uds,
7732 uds_socket.as_deref(),
7733 no_daemon,
7734 local_only,
7735 json,
7736 ),
7737 SessionCommand::List { json } => cmd_session_list(json),
7738 SessionCommand::ListLocal { json } => cmd_session_list_local(json),
7739 SessionCommand::PairAllLocal {
7740 settle_secs,
7741 federation_relay,
7742 json,
7743 } => cmd_session_pair_all_local(settle_secs, &federation_relay, json),
7744 SessionCommand::MeshStatus { stale_secs, json } => {
7745 cmd_session_mesh_status(stale_secs, json)
7746 }
7747 SessionCommand::Env { name, json } => cmd_session_env(name.as_deref(), json),
7748 SessionCommand::Current { json } => cmd_session_current(json),
7749 SessionCommand::Bind { name, json } => cmd_session_bind(name.as_deref(), json),
7750 SessionCommand::Destroy { name, force, json } => cmd_session_destroy(&name, force, json),
7751 }
7752}
7753
7754fn cmd_session_bind(name_arg: Option<&str>, json: bool) -> Result<()> {
7755 let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
7756 let cwd_str = cwd.to_string_lossy().into_owned();
7757
7758 let resolved_name = match name_arg {
7759 Some(n) => crate::session::sanitize_name(n),
7760 None => crate::session::sanitize_name(
7761 cwd.file_name()
7762 .and_then(|s| s.to_str())
7763 .ok_or_else(|| anyhow!("cwd has no basename to derive session name from"))?,
7764 ),
7765 };
7766
7767 let session_home = crate::session::session_dir(&resolved_name)?;
7768 if !session_home.exists() {
7769 bail!(
7770 "session `{resolved_name}` does not exist (looked at {}). Create it first with `wire session new {resolved_name}` or pass an existing name.",
7771 session_home.display()
7772 );
7773 }
7774
7775 let prior = crate::session::read_registry()
7776 .ok()
7777 .and_then(|r| r.by_cwd.get(&cwd_str).cloned());
7778 if prior.as_deref() == Some(resolved_name.as_str()) {
7779 if json {
7780 println!(
7781 "{}",
7782 serde_json::to_string(&json!({
7783 "cwd": cwd_str,
7784 "session": resolved_name,
7785 "changed": false,
7786 }))?
7787 );
7788 } else {
7789 println!("cwd `{cwd_str}` already bound to session `{resolved_name}` (no change)");
7790 }
7791 return Ok(());
7792 }
7793 if let Some(prior_name) = &prior {
7794 eprintln!(
7795 "wire session bind: cwd `{cwd_str}` was bound to `{prior_name}`; overwriting with `{resolved_name}`."
7796 );
7797 }
7798
7799 crate::session::update_registry(|reg| {
7800 reg.by_cwd.insert(cwd_str.clone(), resolved_name.clone());
7801 Ok(())
7802 })?;
7803
7804 if json {
7805 println!(
7806 "{}",
7807 serde_json::to_string(&json!({
7808 "cwd": cwd_str,
7809 "session": resolved_name,
7810 "changed": true,
7811 "previous": prior,
7812 }))?
7813 );
7814 } else {
7815 println!("bound cwd `{cwd_str}` → session `{resolved_name}`");
7816 println!("(next `wire` invocation from this cwd will auto-detect into this session)");
7817 }
7818 Ok(())
7819}
7820
7821fn resolve_session_name(name: Option<&str>) -> Result<String> {
7822 if let Some(n) = name {
7823 return Ok(crate::session::sanitize_name(n));
7824 }
7825 let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
7826 let registry = crate::session::read_registry().unwrap_or_default();
7827 Ok(crate::session::derive_name_from_cwd(&cwd, ®istry))
7828}
7829
7830#[allow(clippy::too_many_arguments)] fn cmd_session_new(
7834 name_arg: Option<&str>,
7835 relay: &str,
7836 with_local: bool,
7837 local_relay: &str,
7838 with_lan: bool,
7839 lan_relay: Option<&str>,
7840 with_uds: bool,
7841 uds_socket: Option<&std::path::Path>,
7842 no_daemon: bool,
7843 local_only: bool,
7844 as_json: bool,
7845) -> Result<()> {
7846 let with_local = with_local || local_only;
7849 if with_lan && lan_relay.is_none() {
7851 bail!("--with-lan requires --lan-relay <url> (e.g. http://192.168.1.50:8771)");
7852 }
7853 if with_uds && uds_socket.is_none() {
7855 bail!("--with-uds requires --uds-socket <path> (e.g. /tmp/wire.sock)");
7856 }
7857 let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
7858 let mut registry = crate::session::read_registry().unwrap_or_default();
7859 let name = match name_arg {
7860 Some(n) => crate::session::sanitize_name(n),
7861 None => crate::session::derive_name_from_cwd(&cwd, ®istry),
7862 };
7863 let session_home = crate::session::session_dir(&name)?;
7864
7865 let already_exists = session_home.exists()
7866 && session_home
7867 .join("config")
7868 .join("wire")
7869 .join("agent-card.json")
7870 .exists();
7871 if already_exists {
7872 registry
7876 .by_cwd
7877 .insert(cwd.to_string_lossy().into_owned(), name.clone());
7878 crate::session::write_registry(®istry)?;
7879 let info = render_session_info(&name, &session_home, &cwd)?;
7880 emit_session_new_result(&info, "already_exists", as_json)?;
7881 if !no_daemon {
7882 ensure_session_daemon(&session_home)?;
7883 }
7884 return Ok(());
7885 }
7886
7887 std::fs::create_dir_all(&session_home)
7888 .with_context(|| format!("creating session dir {session_home:?}"))?;
7889
7890 let init_args: Vec<&str> = if local_only {
7899 vec!["init", &name, "--offline"]
7900 } else {
7901 vec!["init", &name, "--relay", relay]
7902 };
7903 let init_status = run_wire_with_home(&session_home, &init_args)?;
7904 if !init_status.success() {
7905 let how = if local_only {
7906 format!("`wire init {name}` (local-only)")
7907 } else {
7908 format!("`wire init {name} --relay {relay}`")
7909 };
7910 bail!("{how} failed inside session dir {session_home:?}");
7911 }
7912
7913 let effective_handle = if local_only {
7918 name.clone()
7919 } else {
7920 let mut claim_attempt = 0u32;
7921 let mut effective = name.clone();
7922 loop {
7923 claim_attempt += 1;
7924 let status =
7925 run_wire_with_home(&session_home, &["claim", &effective, "--relay", relay])?;
7926 if status.success() {
7927 break;
7928 }
7929 if claim_attempt >= 5 {
7930 bail!(
7931 "5 failed attempts to claim a handle on {relay} for session {name}. \
7932 Try `wire session destroy {name} --force` and re-run with a different name, \
7933 or use `--local-only` if you don't need a federation address."
7934 );
7935 }
7936 let attempt_path = cwd.join(format!("__attempt_{claim_attempt}"));
7937 let suffix = crate::session::derive_name_from_cwd(&attempt_path, ®istry);
7938 let token = suffix
7939 .rsplit('-')
7940 .next()
7941 .filter(|t| t.len() == 4)
7942 .map(str::to_string)
7943 .unwrap_or_else(|| format!("{claim_attempt}"));
7944 effective = format!("{name}-{token}");
7945 }
7946 effective
7947 };
7948
7949 registry
7952 .by_cwd
7953 .insert(cwd.to_string_lossy().into_owned(), name.clone());
7954 crate::session::write_registry(®istry)?;
7955
7956 if with_local {
7967 try_allocate_local_slot(&session_home, &effective_handle, relay, local_relay);
7968 if local_only {
7969 let relay_state_path = session_home.join("config").join("wire").join("relay.json");
7974 let state: Value = std::fs::read(&relay_state_path)
7975 .ok()
7976 .and_then(|b| serde_json::from_slice(&b).ok())
7977 .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
7978 let endpoints = crate::endpoints::self_endpoints(&state);
7979 let has_local = endpoints
7980 .iter()
7981 .any(|e| e.scope == crate::endpoints::EndpointScope::Local);
7982 if !has_local {
7983 bail!(
7984 "--local-only requested but local-relay probe at {local_relay} failed — \
7985 ensure the local relay is running (`wire service install --local-relay`), \
7986 then re-run `wire session new {name} --local-only`."
7987 );
7988 }
7989 }
7990 }
7991
7992 if with_lan && let Some(lan_url) = lan_relay {
7996 try_allocate_lan_slot(&session_home, &effective_handle, lan_url);
7997 }
7998 if with_uds && let Some(socket_path) = uds_socket {
8000 try_allocate_uds_slot(&session_home, &effective_handle, socket_path);
8001 }
8002
8003 if !no_daemon {
8004 ensure_session_daemon(&session_home)?;
8005 }
8006
8007 let info = render_session_info(&name, &session_home, &cwd)?;
8008 emit_session_new_result(&info, "created", as_json)
8009}
8010
8011#[cfg(unix)]
8021fn try_allocate_uds_slot(
8022 session_home: &std::path::Path,
8023 handle: &str,
8024 uds_socket: &std::path::Path,
8025) {
8026 let healthz = match crate::relay_client::uds_request(uds_socket, "GET", "/healthz", &[], b"") {
8029 Ok((200, _)) => true,
8030 Ok((status, body)) => {
8031 eprintln!(
8032 "wire session new: UDS relay probe at {uds_socket:?} returned {status} ({}) — not publishing UDS endpoint",
8033 String::from_utf8_lossy(&body)
8034 );
8035 return;
8036 }
8037 Err(e) => {
8038 eprintln!(
8039 "wire session new: UDS relay at {uds_socket:?} unreachable ({e:#}) — \
8040 not publishing UDS endpoint. Start one with `wire relay-server --uds <path>`."
8041 );
8042 return;
8043 }
8044 };
8045 if !healthz {
8046 return;
8047 }
8048
8049 let alloc_body = serde_json::json!({"handle": handle}).to_string();
8051 let (status, body) = match crate::relay_client::uds_request(
8052 uds_socket,
8053 "POST",
8054 "/v1/slot/allocate",
8055 &[("Content-Type", "application/json")],
8056 alloc_body.as_bytes(),
8057 ) {
8058 Ok(r) => r,
8059 Err(e) => {
8060 eprintln!(
8061 "wire session new: UDS relay slot allocation request failed: {e:#} — not publishing UDS endpoint"
8062 );
8063 return;
8064 }
8065 };
8066 if status >= 300 {
8067 eprintln!(
8068 "wire session new: UDS relay slot allocation returned {status} ({}) — not publishing UDS endpoint",
8069 String::from_utf8_lossy(&body)
8070 );
8071 return;
8072 }
8073 let alloc: crate::relay_client::AllocateResponse = match serde_json::from_slice(&body) {
8074 Ok(a) => a,
8075 Err(e) => {
8076 eprintln!("wire session new: UDS relay returned unparseable allocate response: {e:#}");
8077 return;
8078 }
8079 };
8080
8081 let state_path = session_home.join("config").join("wire").join("relay.json");
8082 let mut state: serde_json::Value = std::fs::read(&state_path)
8083 .ok()
8084 .and_then(|b| serde_json::from_slice(&b).ok())
8085 .unwrap_or_else(|| serde_json::json!({}));
8086
8087 let mut endpoints: Vec<crate::endpoints::Endpoint> = state
8088 .get("self")
8089 .and_then(|s| s.get("endpoints"))
8090 .and_then(|e| e.as_array())
8091 .map(|arr| {
8092 arr.iter()
8093 .filter_map(|v| {
8094 serde_json::from_value::<crate::endpoints::Endpoint>(v.clone()).ok()
8095 })
8096 .collect()
8097 })
8098 .unwrap_or_default();
8099 endpoints.push(crate::endpoints::Endpoint::uds(
8100 format!("unix://{}", uds_socket.display()),
8101 alloc.slot_id.clone(),
8102 alloc.slot_token.clone(),
8103 ));
8104
8105 let self_obj = state
8106 .as_object_mut()
8107 .expect("relay_state root is an object")
8108 .entry("self")
8109 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
8110 if !self_obj.is_object() {
8111 *self_obj = serde_json::Value::Object(serde_json::Map::new());
8112 }
8113 if let Some(obj) = self_obj.as_object_mut() {
8114 obj.insert(
8115 "endpoints".into(),
8116 serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
8117 );
8118 }
8119 if let Err(e) = std::fs::write(
8120 &state_path,
8121 serde_json::to_vec_pretty(&state).expect("relay_state serializable"),
8122 ) {
8123 eprintln!("wire session new: failed to write {state_path:?}: {e}");
8124 return;
8125 }
8126 eprintln!(
8127 "wire session new: UDS slot allocated on unix://{} (slot_id={}) — sister sessions will see this endpoint in your agent-card",
8128 uds_socket.display(),
8129 alloc.slot_id
8130 );
8131}
8132
8133#[cfg(not(unix))]
8134fn try_allocate_uds_slot(
8135 _session_home: &std::path::Path,
8136 _handle: &str,
8137 _uds_socket: &std::path::Path,
8138) {
8139 eprintln!(
8140 "wire session new: --with-uds is Unix-only (Windows lacks AF_UNIX in tokio/reqwest); ignoring"
8141 );
8142}
8143
8144fn try_allocate_lan_slot(session_home: &std::path::Path, handle: &str, lan_relay: &str) {
8154 let probe = match crate::relay_client::build_blocking_client(Some(
8155 std::time::Duration::from_millis(500),
8156 )) {
8157 Ok(c) => c,
8158 Err(e) => {
8159 eprintln!("wire session new: cannot build LAN probe client for {lan_relay}: {e:#}");
8160 return;
8161 }
8162 };
8163 let healthz_url = format!("{}/healthz", lan_relay.trim_end_matches('/'));
8164 match probe.get(&healthz_url).send() {
8165 Ok(resp) if resp.status().is_success() => {}
8166 Ok(resp) => {
8167 eprintln!(
8168 "wire session new: LAN relay probe at {healthz_url} returned {} — not publishing LAN endpoint",
8169 resp.status()
8170 );
8171 return;
8172 }
8173 Err(e) => {
8174 eprintln!(
8175 "wire session new: LAN relay at {lan_relay} unreachable ({}) — not publishing LAN endpoint. \
8176 Start one on the LAN-bound interface with `wire relay-server --bind <LAN-IP>:8771 --local-only`.",
8177 crate::relay_client::format_transport_error(&anyhow::Error::new(e))
8178 );
8179 return;
8180 }
8181 };
8182
8183 let lan_client = crate::relay_client::RelayClient::new(lan_relay);
8184 let alloc = match lan_client.allocate_slot(Some(handle)) {
8185 Ok(a) => a,
8186 Err(e) => {
8187 eprintln!(
8188 "wire session new: LAN relay slot allocation failed: {e:#} — not publishing LAN endpoint"
8189 );
8190 return;
8191 }
8192 };
8193
8194 let state_path = session_home.join("config").join("wire").join("relay.json");
8195 let mut state: serde_json::Value = std::fs::read(&state_path)
8196 .ok()
8197 .and_then(|b| serde_json::from_slice(&b).ok())
8198 .unwrap_or_else(|| serde_json::json!({}));
8199
8200 let mut endpoints: Vec<crate::endpoints::Endpoint> = state
8203 .get("self")
8204 .and_then(|s| s.get("endpoints"))
8205 .and_then(|e| e.as_array())
8206 .map(|arr| {
8207 arr.iter()
8208 .filter_map(|v| {
8209 serde_json::from_value::<crate::endpoints::Endpoint>(v.clone()).ok()
8210 })
8211 .collect()
8212 })
8213 .unwrap_or_default();
8214 endpoints.push(crate::endpoints::Endpoint::lan(
8215 lan_relay.trim_end_matches('/').to_string(),
8216 alloc.slot_id.clone(),
8217 alloc.slot_token.clone(),
8218 ));
8219
8220 let self_obj = state
8221 .as_object_mut()
8222 .expect("relay_state root is an object")
8223 .entry("self")
8224 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
8225 if !self_obj.is_object() {
8226 *self_obj = serde_json::Value::Object(serde_json::Map::new());
8227 }
8228 if let Some(obj) = self_obj.as_object_mut() {
8229 obj.insert(
8230 "endpoints".into(),
8231 serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
8232 );
8233 }
8234 if let Err(e) = std::fs::write(
8235 &state_path,
8236 serde_json::to_vec_pretty(&state).expect("relay_state serializable"),
8237 ) {
8238 eprintln!("wire session new: failed to write {state_path:?}: {e}");
8239 return;
8240 }
8241 eprintln!(
8242 "wire session new: LAN slot allocated on {lan_relay} (slot_id={}) — peers will see this endpoint in your agent-card",
8243 alloc.slot_id
8244 );
8245}
8246
8247fn try_allocate_local_slot(
8255 session_home: &std::path::Path,
8256 handle: &str,
8257 _federation_relay: &str,
8258 local_relay: &str,
8259) {
8260 let probe = match crate::relay_client::build_blocking_client(Some(
8263 std::time::Duration::from_millis(500),
8264 )) {
8265 Ok(c) => c,
8266 Err(e) => {
8267 eprintln!("wire session new: cannot build probe client for {local_relay}: {e:#}");
8268 return;
8269 }
8270 };
8271 let healthz_url = format!("{}/healthz", local_relay.trim_end_matches('/'));
8272 match probe.get(&healthz_url).send() {
8273 Ok(resp) if resp.status().is_success() => {}
8274 Ok(resp) => {
8275 eprintln!(
8276 "wire session new: local relay probe at {healthz_url} returned {} — staying federation-only",
8277 resp.status()
8278 );
8279 return;
8280 }
8281 Err(e) => {
8282 eprintln!(
8283 "wire session new: local relay at {local_relay} unreachable ({}) — staying federation-only. \
8284 Start one with `wire relay-server --bind 127.0.0.1:8771 --local-only`.",
8285 crate::relay_client::format_transport_error(&anyhow::Error::new(e))
8286 );
8287 return;
8288 }
8289 };
8290
8291 let local_client = crate::relay_client::RelayClient::new(local_relay);
8293 let alloc = match local_client.allocate_slot(Some(handle)) {
8294 Ok(a) => a,
8295 Err(e) => {
8296 eprintln!(
8297 "wire session new: local relay slot allocation failed: {e:#} — staying federation-only"
8298 );
8299 return;
8300 }
8301 };
8302
8303 let state_path = session_home.join("config").join("wire").join("relay.json");
8318 let mut state: serde_json::Value = std::fs::read(&state_path)
8319 .ok()
8320 .and_then(|b| serde_json::from_slice(&b).ok())
8321 .unwrap_or_else(|| serde_json::json!({}));
8322 let fed_endpoint = state.get("self").and_then(|s| {
8325 let url = s.get("relay_url").and_then(serde_json::Value::as_str)?;
8326 let slot_id = s.get("slot_id").and_then(serde_json::Value::as_str)?;
8327 let slot_token = s.get("slot_token").and_then(serde_json::Value::as_str)?;
8328 Some(crate::endpoints::Endpoint::federation(
8329 url.to_string(),
8330 slot_id.to_string(),
8331 slot_token.to_string(),
8332 ))
8333 });
8334
8335 let local_endpoint = crate::endpoints::Endpoint::local(
8336 local_relay.trim_end_matches('/').to_string(),
8337 alloc.slot_id.clone(),
8338 alloc.slot_token.clone(),
8339 );
8340
8341 let mut endpoints: Vec<crate::endpoints::Endpoint> = Vec::new();
8342 if let Some(f) = fed_endpoint.clone() {
8343 endpoints.push(f);
8344 }
8345 endpoints.push(local_endpoint);
8346
8347 let (legacy_relay, legacy_slot_id, legacy_slot_token) = match fed_endpoint.clone() {
8357 Some(f) => (f.relay_url, f.slot_id, f.slot_token),
8358 None => (
8359 local_relay.trim_end_matches('/').to_string(),
8360 alloc.slot_id.clone(),
8361 alloc.slot_token.clone(),
8362 ),
8363 };
8364 let self_obj = state
8365 .as_object_mut()
8366 .expect("relay_state root is an object")
8367 .entry("self")
8368 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
8369 if !self_obj.is_object() {
8372 *self_obj = serde_json::Value::Object(serde_json::Map::new());
8373 }
8374 if let Some(obj) = self_obj.as_object_mut() {
8375 obj.insert("relay_url".into(), serde_json::Value::String(legacy_relay));
8376 obj.insert("slot_id".into(), serde_json::Value::String(legacy_slot_id));
8377 obj.insert(
8378 "slot_token".into(),
8379 serde_json::Value::String(legacy_slot_token),
8380 );
8381 obj.insert(
8382 "endpoints".into(),
8383 serde_json::to_value(&endpoints).unwrap_or(serde_json::Value::Null),
8384 );
8385 }
8386
8387 if let Err(e) = std::fs::write(
8388 &state_path,
8389 serde_json::to_vec_pretty(&state).unwrap_or_default(),
8390 ) {
8391 eprintln!(
8392 "wire session new: persisting dual-slot relay_state at {state_path:?} failed: {e}"
8393 );
8394 return;
8395 }
8396 eprintln!(
8397 "wire session new: local slot allocated on {local_relay} (slot_id={})",
8398 alloc.slot_id
8399 );
8400}
8401
8402fn render_session_info(
8403 name: &str,
8404 session_home: &std::path::Path,
8405 cwd: &std::path::Path,
8406) -> Result<serde_json::Value> {
8407 let card_path = session_home
8408 .join("config")
8409 .join("wire")
8410 .join("agent-card.json");
8411 let (did, handle) = if card_path.exists() {
8412 let card: Value = serde_json::from_slice(&std::fs::read(&card_path)?)?;
8413 let did = card
8414 .get("did")
8415 .and_then(Value::as_str)
8416 .unwrap_or("")
8417 .to_string();
8418 let handle = card
8419 .get("handle")
8420 .and_then(Value::as_str)
8421 .map(str::to_string)
8422 .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
8423 (did, handle)
8424 } else {
8425 (String::new(), String::new())
8426 };
8427 Ok(json!({
8428 "name": name,
8429 "home_dir": session_home.to_string_lossy(),
8430 "cwd": cwd.to_string_lossy(),
8431 "did": did,
8432 "handle": handle,
8433 "export": format!("export WIRE_HOME={}", session_home.to_string_lossy()),
8434 }))
8435}
8436
8437fn emit_session_new_result(info: &serde_json::Value, status: &str, as_json: bool) -> Result<()> {
8438 if as_json {
8439 let mut obj = info.clone();
8440 obj["status"] = json!(status);
8441 println!("{}", serde_json::to_string(&obj)?);
8442 } else {
8443 let name = info["name"].as_str().unwrap_or("?");
8444 let handle = info["handle"].as_str().unwrap_or("?");
8445 let home = info["home_dir"].as_str().unwrap_or("?");
8446 let did = info["did"].as_str().unwrap_or("?");
8447 let export = info["export"].as_str().unwrap_or("?");
8448 let prefix = if status == "already_exists" {
8449 "session already exists (re-registered cwd)"
8450 } else {
8451 "session created"
8452 };
8453 println!(
8454 "{prefix}\n name: {name}\n handle: {handle}\n did: {did}\n home: {home}\n\nactivate with:\n {export}"
8455 );
8456 }
8457 Ok(())
8458}
8459
8460fn run_wire_with_home(
8461 session_home: &std::path::Path,
8462 args: &[&str],
8463) -> Result<std::process::ExitStatus> {
8464 let bin = std::env::current_exe().with_context(|| "locating self exe")?;
8465 let status = std::process::Command::new(&bin)
8466 .env("WIRE_HOME", session_home)
8467 .env_remove("RUST_LOG")
8468 .env("WIRE_AUTO_INIT", "0")
8471 .args(args)
8472 .status()
8473 .with_context(|| format!("spawning `wire {}`", args.join(" ")))?;
8474 Ok(status)
8475}
8476
8477pub fn maybe_auto_init_cwd_session(label: &str) {
8496 if std::env::var("WIRE_HOME").is_ok() {
8497 return; }
8499 if std::env::var("WIRE_AUTO_INIT").as_deref() == Ok("0") {
8500 return; }
8502 let cwd = match std::env::current_dir() {
8503 Ok(c) => c,
8504 Err(_) => return,
8505 };
8506 if crate::session::detect_session_wire_home(&cwd).is_some() {
8509 return;
8510 }
8511
8512 use fs2::FileExt;
8529 let sessions_root = match crate::session::sessions_root() {
8530 Ok(r) => r,
8531 Err(_) => return,
8532 };
8533 if let Err(e) = std::fs::create_dir_all(&sessions_root) {
8534 eprintln!("wire {label}: auto-init: failed to create sessions root {sessions_root:?}: {e}");
8535 return;
8536 }
8537 let lock_path = sessions_root.join(".auto-init.lock");
8538 let lock_file = match std::fs::OpenOptions::new()
8539 .create(true)
8540 .truncate(false)
8541 .read(true)
8542 .write(true)
8543 .open(&lock_path)
8544 {
8545 Ok(f) => f,
8546 Err(e) => {
8547 eprintln!(
8548 "wire {label}: auto-init: cannot open lockfile {lock_path:?}: {e} — falling back to default identity"
8549 );
8550 return;
8551 }
8552 };
8553 if let Err(e) = lock_file.lock_exclusive() {
8554 eprintln!(
8555 "wire {label}: auto-init: flock {lock_path:?} failed: {e} — falling back to default identity"
8556 );
8557 return;
8558 }
8559 let registry = crate::session::read_registry().unwrap_or_default();
8564 let name = crate::session::derive_name_from_cwd(&cwd, ®istry);
8565 let session_home = match crate::session::session_dir(&name) {
8566 Ok(h) => h,
8567 Err(_) => {
8568 let _ = fs2::FileExt::unlock(&lock_file);
8569 return;
8570 }
8571 };
8572 let agent_card_path = session_home
8573 .join("config")
8574 .join("wire")
8575 .join("agent-card.json");
8576 let needs_init = !agent_card_path.exists();
8577
8578 if needs_init {
8579 if let Err(e) = std::fs::create_dir_all(&session_home) {
8580 eprintln!(
8581 "wire {label}: auto-init: failed to create session dir {session_home:?}: {e}"
8582 );
8583 let _ = fs2::FileExt::unlock(&lock_file);
8584 return;
8585 }
8586 match run_wire_with_home(&session_home, &["init", &name, "--offline"]) {
8591 Ok(status) if status.success() => {}
8592 Ok(status) => {
8593 eprintln!(
8594 "wire {label}: auto-init: `wire init {name}` exited non-zero ({status}) — falling back to default identity"
8595 );
8596 let _ = fs2::FileExt::unlock(&lock_file);
8597 return;
8598 }
8599 Err(e) => {
8600 eprintln!(
8601 "wire {label}: auto-init: failed to spawn `wire init {name}`: {e:#} — falling back to default identity"
8602 );
8603 let _ = fs2::FileExt::unlock(&lock_file);
8604 return;
8605 }
8606 }
8607 try_allocate_local_slot(
8614 &session_home,
8615 &name,
8616 "https://wireup.net",
8617 "http://127.0.0.1:8771",
8618 );
8619 } else {
8620 if std::env::var("WIRE_QUIET_AUTOSESSION").is_err() {
8624 eprintln!(
8625 "wire {label}: auto-init: session `{name}` already exists (concurrent mcp peer won the race) — adopting"
8626 );
8627 }
8628 }
8629 let cwd_key = cwd.to_string_lossy().into_owned();
8639 let name_for_reg = name.clone();
8640 if let Err(e) = crate::session::update_registry(|reg| {
8641 reg.by_cwd.insert(cwd_key, name_for_reg);
8642 Ok(())
8643 }) {
8644 eprintln!("wire {label}: auto-init: failed to update registry: {e:#}");
8645 }
8647 let _ = fs2::FileExt::unlock(&lock_file);
8650
8651 if std::env::var("WIRE_QUIET_AUTOSESSION").is_err() {
8652 eprintln!(
8653 "wire {label}: auto-init: created session `{name}` for cwd `{}` → WIRE_HOME=`{}`",
8654 cwd.display(),
8655 session_home.display()
8656 );
8657 }
8658 unsafe {
8661 std::env::set_var("WIRE_HOME", &session_home);
8662 }
8663}
8664
8665fn ensure_session_daemon(session_home: &std::path::Path) -> Result<()> {
8666 let pidfile = session_home.join("state").join("wire").join("daemon.pid");
8669 if pidfile.exists() {
8670 let bytes = std::fs::read(&pidfile).unwrap_or_default();
8671 let pid: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
8672 v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
8673 } else {
8674 String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
8675 };
8676 if let Some(p) = pid {
8677 let alive = {
8678 #[cfg(target_os = "linux")]
8679 {
8680 std::path::Path::new(&format!("/proc/{p}")).exists()
8681 }
8682 #[cfg(not(target_os = "linux"))]
8683 {
8684 std::process::Command::new("kill")
8685 .args(["-0", &p.to_string()])
8686 .output()
8687 .map(|o| o.status.success())
8688 .unwrap_or(false)
8689 }
8690 };
8691 if alive {
8692 return Ok(());
8693 }
8694 }
8695 }
8696
8697 let bin = std::env::current_exe().with_context(|| "locating self exe")?;
8700 let log_path = session_home.join("state").join("wire").join("daemon.log");
8701 if let Some(parent) = log_path.parent() {
8702 std::fs::create_dir_all(parent).ok();
8703 }
8704 let log_file = std::fs::OpenOptions::new()
8705 .create(true)
8706 .append(true)
8707 .open(&log_path)
8708 .with_context(|| format!("opening daemon log {log_path:?}"))?;
8709 let log_err = log_file.try_clone()?;
8710 std::process::Command::new(&bin)
8711 .env("WIRE_HOME", session_home)
8712 .env_remove("RUST_LOG")
8713 .args(["daemon", "--interval", "5"])
8714 .stdout(log_file)
8715 .stderr(log_err)
8716 .stdin(std::process::Stdio::null())
8717 .spawn()
8718 .with_context(|| "spawning session-local `wire daemon`")?;
8719 Ok(())
8720}
8721
8722fn cmd_session_list(as_json: bool) -> Result<()> {
8723 let items = crate::session::list_sessions()?;
8724 if as_json {
8725 println!("{}", serde_json::to_string(&items)?);
8726 return Ok(());
8727 }
8728 if items.is_empty() {
8729 println!("no sessions on this machine. `wire session new` to create one.");
8730 return Ok(());
8731 }
8732 println!(
8733 "{:<22} {:<24} {:<24} {:<10} CWD",
8734 "PERSONA", "NAME", "HANDLE", "DAEMON"
8735 );
8736 for s in items {
8737 let plain = s
8741 .character
8742 .as_ref()
8743 .map(|c| c.short())
8744 .unwrap_or_else(|| "?".to_string());
8745 let colored = s
8746 .character
8747 .as_ref()
8748 .map(|c| c.colored())
8749 .unwrap_or_else(|| "?".to_string());
8750 let displayed_width = plain.chars().count() + 1; let pad = 22usize.saturating_sub(displayed_width);
8755 println!(
8756 "{}{} {:<24} {:<24} {:<10} {}",
8757 colored,
8758 " ".repeat(pad),
8759 s.name,
8760 s.handle.as_deref().unwrap_or("?"),
8761 if s.daemon_running { "running" } else { "down" },
8762 s.cwd.as_deref().unwrap_or("(no cwd registered)"),
8763 );
8764 }
8765 Ok(())
8766}
8767
8768fn cmd_session_list_local(as_json: bool) -> Result<()> {
8780 let listing = crate::session::list_local_sessions()?;
8781 if as_json {
8782 println!("{}", serde_json::to_string(&listing)?);
8783 return Ok(());
8784 }
8785
8786 if listing.local.is_empty() && listing.federation_only.is_empty() {
8787 println!(
8788 "no sessions on this machine. `wire session new --with-local` to create one \
8789 with a local-relay endpoint (start the relay first: \
8790 `wire relay-server --bind 127.0.0.1:8771 --local-only`)."
8791 );
8792 return Ok(());
8793 }
8794
8795 if listing.local.is_empty() {
8796 println!(
8797 "no sister sessions reachable via a local relay. \
8798 Re-run `wire session new --with-local` to add a Local endpoint, or \
8799 start a local relay with `wire relay-server --bind 127.0.0.1:8771 --local-only`."
8800 );
8801 } else {
8802 let mut keys: Vec<&String> = listing.local.keys().collect();
8804 keys.sort();
8805 for relay_url in keys {
8806 let group = &listing.local[relay_url];
8807 println!("LOCAL RELAY: {relay_url}");
8808 println!(" {:<24} {:<32} {:<10} CWD", "NAME", "HANDLE", "DAEMON");
8809 for s in group {
8810 println!(
8811 " {:<24} {:<32} {:<10} {}",
8812 s.name,
8813 s.handle.as_deref().unwrap_or("?"),
8814 if s.daemon_running { "running" } else { "down" },
8815 s.cwd.as_deref().unwrap_or("(no cwd registered)"),
8816 );
8817 }
8818 println!();
8819 }
8820 }
8821
8822 if !listing.federation_only.is_empty() {
8823 println!("federation-only (no local endpoint):");
8824 for s in &listing.federation_only {
8825 println!(
8826 " {:<24} {:<32} {}",
8827 s.name,
8828 s.handle.as_deref().unwrap_or("?"),
8829 s.cwd.as_deref().unwrap_or("(no cwd registered)"),
8830 );
8831 }
8832 }
8833 Ok(())
8834}
8835
8836fn cmd_session_pair_all_local(
8855 settle_secs: u64,
8856 federation_relay: &str,
8857 as_json: bool,
8858) -> Result<()> {
8859 use std::collections::BTreeSet;
8860 use std::time::Duration;
8861
8862 let listing = crate::session::list_local_sessions()?;
8863 let mut by_name: std::collections::BTreeMap<String, crate::session::LocalSessionView> =
8867 Default::default();
8868 for group in listing.local.into_values() {
8869 for s in group {
8870 by_name.entry(s.name.clone()).or_insert(s);
8871 }
8872 }
8873 let sessions: Vec<crate::session::LocalSessionView> = by_name.into_values().collect();
8874
8875 if sessions.len() < 2 {
8876 let msg = format!(
8877 "{} sister session(s) with a local endpoint — need at least 2 to pair.",
8878 sessions.len()
8879 );
8880 if as_json {
8881 println!(
8882 "{}",
8883 serde_json::to_string(&json!({
8884 "sessions": sessions.iter().map(|s| &s.name).collect::<Vec<_>>(),
8885 "pairs_attempted": 0,
8886 "pairs_succeeded": 0,
8887 "pairs_skipped_already_paired": 0,
8888 "pairs_failed": 0,
8889 "note": msg,
8890 }))?
8891 );
8892 } else {
8893 println!("{msg}");
8894 if let Some(s) = sessions.first() {
8895 println!(" - {} ({})", s.name, s.cwd.as_deref().unwrap_or("?"));
8896 }
8897 println!("Use `wire session new --with-local` to add more.");
8898 }
8899 return Ok(());
8900 }
8901
8902 let fed_host = host_of_url(federation_relay);
8903 if fed_host.is_empty() {
8904 bail!(
8905 "federation_relay `{federation_relay}` has no parseable host — \
8906 pass a full URL like `https://wireup.net`."
8907 );
8908 }
8909
8910 let mut attempted = 0u32;
8912 let mut succeeded = 0u32;
8913 let mut skipped_already = 0u32;
8914 let mut failed = 0u32;
8915 let mut per_pair: Vec<Value> = Vec::new();
8916
8917 for i in 0..sessions.len() {
8918 for j in (i + 1)..sessions.len() {
8919 let a = &sessions[i];
8920 let b = &sessions[j];
8921 attempted += 1;
8922
8923 let a_handle = a.handle.as_deref().unwrap_or(a.name.as_str());
8929 let b_handle = b.handle.as_deref().unwrap_or(b.name.as_str());
8930 let a_pinned_b = session_has_peer(&a.home_dir, b_handle);
8931 let b_pinned_a = session_has_peer(&b.home_dir, a_handle);
8932 if a_pinned_b && b_pinned_a {
8933 skipped_already += 1;
8934 per_pair.push(json!({
8935 "from": a.name,
8936 "to": b.name,
8937 "status": "already_paired",
8938 }));
8939 continue;
8940 }
8941
8942 let pair_result = drive_bilateral_pair(
8943 &a.home_dir,
8944 &a.name,
8945 &b.home_dir,
8946 &b.name,
8947 &fed_host,
8948 federation_relay,
8949 settle_secs,
8950 );
8951
8952 match pair_result {
8953 Ok(()) => {
8954 succeeded += 1;
8955 per_pair.push(json!({
8956 "from": a.name,
8957 "to": b.name,
8958 "status": "paired",
8959 }));
8960 }
8961 Err(e) => {
8962 failed += 1;
8963 let detail = format!("{e:#}");
8964 per_pair.push(json!({
8965 "from": a.name,
8966 "to": b.name,
8967 "status": "failed",
8968 "error": detail,
8969 }));
8970 }
8971 }
8972
8973 std::thread::sleep(Duration::from_millis(200));
8976 }
8977 }
8978
8979 let _ = BTreeSet::<String>::new(); let summary = json!({
8981 "sessions": sessions.iter().map(|s| s.name.clone()).collect::<Vec<_>>(),
8982 "pairs_attempted": attempted,
8983 "pairs_succeeded": succeeded,
8984 "pairs_skipped_already_paired": skipped_already,
8985 "pairs_failed": failed,
8986 "results": per_pair,
8987 });
8988 if as_json {
8989 println!("{}", serde_json::to_string(&summary)?);
8990 } else {
8991 println!(
8992 "wire session pair-all-local: {} session(s), {} pair(s) attempted",
8993 sessions.len(),
8994 attempted
8995 );
8996 println!(" paired: {succeeded}");
8997 println!(" skipped (already pinned): {skipped_already}");
8998 println!(" failed: {failed}");
8999 for entry in summary["results"].as_array().unwrap_or(&vec![]) {
9000 let from = entry["from"].as_str().unwrap_or("?");
9001 let to = entry["to"].as_str().unwrap_or("?");
9002 let status = entry["status"].as_str().unwrap_or("?");
9003 let err = entry.get("error").and_then(Value::as_str).unwrap_or("");
9004 if err.is_empty() {
9005 println!(" {from:<24} ↔ {to:<24} {status}");
9006 } else {
9007 println!(" {from:<24} ↔ {to:<24} {status} — {err}");
9008 }
9009 }
9010 }
9011 Ok(())
9012}
9013
9014fn session_has_peer(session_home: &std::path::Path, peer_name: &str) -> bool {
9017 val_session_relay_state(session_home)
9018 .and_then(|v| v.get("peers").cloned())
9019 .and_then(|p| p.get(peer_name).cloned())
9020 .is_some()
9021}
9022
9023fn val_session_relay_state(session_home: &std::path::Path) -> Option<Value> {
9028 let path = session_home.join("config").join("wire").join("relay.json");
9029 let bytes = std::fs::read(&path).ok()?;
9030 serde_json::from_slice(&bytes).ok()
9031}
9032
9033fn cmd_session_mesh_status(stale_secs: u64, as_json: bool) -> Result<()> {
9037 use std::collections::BTreeMap;
9038
9039 let listing = crate::session::list_local_sessions()?;
9042 let mut by_name: BTreeMap<String, crate::session::LocalSessionView> = BTreeMap::new();
9043 for group in listing.local.into_values() {
9044 for s in group {
9045 by_name.entry(s.name.clone()).or_insert(s);
9046 }
9047 }
9048 let sessions: Vec<crate::session::LocalSessionView> = by_name.into_values().collect();
9049 let federation_only = listing.federation_only;
9050
9051 if sessions.is_empty() {
9052 let msg = "no sister sessions with a local endpoint on this machine.".to_string();
9053 if as_json {
9054 println!(
9055 "{}",
9056 serde_json::to_string(&json!({
9057 "sessions": [],
9058 "edges": [],
9059 "local_relay": null,
9060 "federation_only": federation_only.iter().map(|f| &f.name).collect::<Vec<_>>(),
9061 "summary": {
9062 "session_count": 0,
9063 "edge_count": 0,
9064 "healthy": 0,
9065 "stale": 0,
9066 "asymmetric": 0,
9067 },
9068 "note": msg,
9069 }))?
9070 );
9071 } else {
9072 println!("{msg}");
9073 println!("Use `wire session new --with-local` to create one.");
9074 }
9075 return Ok(());
9076 }
9077
9078 struct SessionState {
9080 view: crate::session::LocalSessionView,
9081 relay_state: Value,
9082 local_relay_url: Option<String>,
9083 }
9084 let mut sstates: Vec<SessionState> = Vec::with_capacity(sessions.len());
9085 for s in sessions {
9086 let relay_state = val_session_relay_state(&s.home_dir)
9087 .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
9088 let local_relay_url = s.local_endpoints.first().map(|e| e.relay_url.clone());
9089 sstates.push(SessionState {
9090 view: s,
9091 relay_state,
9092 local_relay_url,
9093 });
9094 }
9095
9096 let mut local_relays: BTreeMap<String, bool> = BTreeMap::new();
9099 for s in &sstates {
9100 if let Some(url) = &s.local_relay_url
9101 && !local_relays.contains_key(url)
9102 {
9103 let healthy = probe_relay_healthz(url);
9104 local_relays.insert(url.clone(), healthy);
9105 }
9106 }
9107
9108 let now = std::time::SystemTime::now()
9109 .duration_since(std::time::UNIX_EPOCH)
9110 .map(|d| d.as_secs())
9111 .unwrap_or(0);
9112
9113 let mut edges: Vec<Value> = Vec::new();
9117 let mut healthy_count = 0u32;
9118 let mut stale_count = 0u32;
9119 let mut asymmetric_count = 0u32;
9120
9121 for i in 0..sstates.len() {
9122 for j in (i + 1)..sstates.len() {
9123 let a = &sstates[i];
9124 let b = &sstates[j];
9125 let b_key = b.view.handle.as_deref().unwrap_or(b.view.name.as_str());
9130 let a_key = a.view.handle.as_deref().unwrap_or(a.view.name.as_str());
9131 let a_to_b = probe_directed_edge(&a.relay_state, b_key, now);
9132 let b_to_a = probe_directed_edge(&b.relay_state, a_key, now);
9133
9134 let bilateral = a_to_b.pinned && b_to_a.pinned;
9135 let scope = match (a_to_b.scope.as_deref(), b_to_a.scope.as_deref()) {
9139 (Some("local"), _) | (_, Some("local")) => "local",
9140 (Some("federation"), _) | (_, Some("federation")) => "federation",
9141 _ => "unknown",
9142 };
9143
9144 let mut status = if bilateral { "healthy" } else { "asymmetric" };
9147 if bilateral {
9148 let either_stale = [&a_to_b, &b_to_a].iter().any(|d| match d.silent_secs {
9149 Some(s) => s > stale_secs,
9150 None => d.probed,
9151 });
9152 if either_stale {
9153 status = "stale";
9154 }
9155 }
9156
9157 match status {
9158 "healthy" => healthy_count += 1,
9159 "stale" => stale_count += 1,
9160 "asymmetric" => asymmetric_count += 1,
9161 _ => {}
9162 }
9163
9164 edges.push(json!({
9165 "from": a.view.name,
9166 "to": b.view.name,
9167 "bilateral": bilateral,
9168 "scope": scope,
9169 "status": status,
9170 "directions": {
9171 a.view.name.clone(): direction_summary(&a_to_b),
9172 b.view.name.clone(): direction_summary(&b_to_a),
9173 },
9174 }));
9175 }
9176 }
9177
9178 let summary = json!({
9179 "sessions": sstates.iter().map(|s| json!({
9180 "name": s.view.name,
9181 "handle": s.view.handle,
9182 "cwd": s.view.cwd,
9183 "daemon_running": s.view.daemon_running,
9184 "local_relay": s.local_relay_url,
9185 })).collect::<Vec<_>>(),
9186 "edges": edges,
9187 "local_relays": local_relays.iter().map(|(url, healthy)| json!({
9188 "url": url,
9189 "healthy": healthy,
9190 })).collect::<Vec<_>>(),
9191 "federation_only": federation_only.iter().map(|f| &f.name).collect::<Vec<_>>(),
9192 "summary": {
9193 "session_count": sstates.len(),
9194 "edge_count": edges.len(),
9195 "healthy": healthy_count,
9196 "stale": stale_count,
9197 "asymmetric": asymmetric_count,
9198 "stale_threshold_secs": stale_secs,
9199 },
9200 });
9201
9202 if as_json {
9203 println!("{}", serde_json::to_string(&summary)?);
9204 return Ok(());
9205 }
9206
9207 println!(
9208 "wire mesh: {} session(s), {} edge(s)",
9209 sstates.len(),
9210 edges.len()
9211 );
9212 for (url, healthy) in &local_relays {
9213 let tick = if *healthy { "✓" } else { "✗" };
9214 println!(" local-relay {url} {tick}");
9215 }
9216 if !federation_only.is_empty() {
9217 print!(" federation-only sessions:");
9218 for f in &federation_only {
9219 print!(" {}", f.name);
9220 }
9221 println!();
9222 }
9223
9224 let names: Vec<&str> = sstates.iter().map(|s| s.view.name.as_str()).collect();
9226 let col_w = names.iter().map(|n| n.len()).max().unwrap_or(8).max(7) + 1;
9227 print!("\n{:>col_w$}", "", col_w = col_w);
9228 for n in &names {
9229 print!("{:>col_w$}", n, col_w = col_w);
9230 }
9231 println!();
9232 for (i, row) in names.iter().enumerate() {
9233 print!("{:>col_w$}", row, col_w = col_w);
9234 for (j, col) in names.iter().enumerate() {
9235 let cell = if i == j {
9236 "self".to_string()
9237 } else {
9238 let d = probe_directed_edge(&sstates[i].relay_state, col, now);
9239 match d.scope.as_deref() {
9240 Some("local") => "local".to_string(),
9241 Some("federation") => "fed".to_string(),
9242 _ => "—".to_string(),
9243 }
9244 };
9245 print!("{:>col_w$}", cell, col_w = col_w);
9246 }
9247 println!();
9248 }
9249
9250 println!("\nHealth (stale threshold: {stale_secs}s):");
9251 for e in &edges {
9252 let from = e["from"].as_str().unwrap_or("?");
9253 let to = e["to"].as_str().unwrap_or("?");
9254 let scope = e["scope"].as_str().unwrap_or("?");
9255 let status = e["status"].as_str().unwrap_or("?");
9256 let mark = match status {
9257 "healthy" => "✓",
9258 "stale" => "⚠",
9259 "asymmetric" => "!",
9260 _ => "?",
9261 };
9262 let dirs = e["directions"].as_object().cloned().unwrap_or_default();
9263 let mut details: Vec<String> = Vec::new();
9264 for (who, d) in &dirs {
9265 let silent = d.get("silent_secs").and_then(Value::as_u64);
9266 let pinned = d.get("pinned").and_then(Value::as_bool).unwrap_or(false);
9267 let probed = d.get("probed").and_then(Value::as_bool).unwrap_or(false);
9268 let label = match (pinned, probed, silent) {
9269 (false, _, _) => format!("{who} has not pinned"),
9270 (true, false, _) => format!("{who} pinned but no endpoint to probe"),
9271 (true, true, Some(s)) if s <= stale_secs => format!("{who} fresh ({s}s)"),
9272 (true, true, Some(s)) => format!("{who} silent {s}s"),
9273 (true, true, None) => format!("{who} never pulled"),
9274 };
9275 details.push(label);
9276 }
9277 println!(
9278 " {mark} {from} ↔ {to} scope={scope} {status:>10} [{}]",
9279 details.join(" | ")
9280 );
9281 }
9282 Ok(())
9283}
9284
9285#[derive(Default)]
9286struct DirectedEdge {
9287 pinned: bool,
9288 scope: Option<String>,
9289 last_pull_at_unix: Option<u64>,
9290 silent_secs: Option<u64>,
9291 probed: bool,
9292 event_count: usize,
9293}
9294
9295fn probe_directed_edge(from_state: &Value, to_name: &str, now: u64) -> DirectedEdge {
9301 let pinned = from_state
9302 .get("peers")
9303 .and_then(|p| p.get(to_name))
9304 .is_some();
9305 if !pinned {
9306 return DirectedEdge::default();
9307 }
9308 let endpoints = crate::endpoints::peer_endpoints_in_priority_order(from_state, to_name);
9309 let ep = match endpoints.into_iter().next() {
9310 Some(e) => e,
9311 None => {
9312 return DirectedEdge {
9313 pinned: true,
9314 ..Default::default()
9315 };
9316 }
9317 };
9318 let scope = Some(
9319 match ep.scope {
9320 crate::endpoints::EndpointScope::Local => "local",
9321 crate::endpoints::EndpointScope::Lan => "lan",
9322 crate::endpoints::EndpointScope::Uds => "uds",
9323 crate::endpoints::EndpointScope::Federation => "federation",
9324 }
9325 .to_string(),
9326 );
9327 let client = crate::relay_client::RelayClient::new(&ep.relay_url);
9328 let (count, last) = client
9329 .slot_state(&ep.slot_id, &ep.slot_token)
9330 .unwrap_or((0, None));
9331 let silent = last.map(|t| now.saturating_sub(t));
9332 DirectedEdge {
9333 pinned: true,
9334 scope,
9335 last_pull_at_unix: last,
9336 silent_secs: silent,
9337 probed: true,
9338 event_count: count,
9339 }
9340}
9341
9342fn direction_summary(d: &DirectedEdge) -> Value {
9343 json!({
9344 "pinned": d.pinned,
9345 "scope": d.scope,
9346 "probed": d.probed,
9347 "last_pull_at_unix": d.last_pull_at_unix,
9348 "silent_secs": d.silent_secs,
9349 "event_count": d.event_count,
9350 })
9351}
9352
9353fn probe_relay_healthz(url: &str) -> bool {
9355 let probe_url = format!("{}/healthz", url.trim_end_matches('/'));
9356 let client = match reqwest::blocking::Client::builder()
9357 .timeout(std::time::Duration::from_millis(500))
9358 .build()
9359 {
9360 Ok(c) => c,
9361 Err(_) => return false,
9362 };
9363 match client.get(&probe_url).send() {
9364 Ok(r) => r.status().is_success(),
9365 Err(_) => false,
9366 }
9367}
9368
9369fn drive_bilateral_pair(
9384 a_home: &std::path::Path,
9385 a_name: &str,
9386 b_home: &std::path::Path,
9387 b_name: &str,
9388 _fed_host: &str,
9389 _federation_relay: &str,
9390 settle_secs: u64,
9391) -> Result<()> {
9392 use std::time::Duration;
9393 let bin = std::env::current_exe().context("locating self exe")?;
9394
9395 let run = |home: &std::path::Path, args: &[&str]| -> Result<()> {
9396 let out = std::process::Command::new(&bin)
9397 .env("WIRE_HOME", home)
9398 .env_remove("RUST_LOG")
9399 .args(args)
9400 .output()
9401 .with_context(|| format!("spawning `wire {}`", args.join(" ")))?;
9402 if !out.status.success() {
9403 bail!(
9404 "`wire {}` failed: stderr={}",
9405 args.join(" "),
9406 String::from_utf8_lossy(&out.stderr).trim()
9407 );
9408 }
9409 Ok(())
9410 };
9411
9412 let read_card_handle = |home: &std::path::Path| -> Result<String> {
9417 let card_path = home.join("config").join("wire").join("agent-card.json");
9418 let bytes = std::fs::read(&card_path)
9419 .with_context(|| format!("reading agent-card at {card_path:?}"))?;
9420 let card: Value = serde_json::from_slice(&bytes)?;
9421 card.get("handle")
9422 .and_then(Value::as_str)
9423 .map(str::to_string)
9424 .ok_or_else(|| anyhow!("agent-card at {card_path:?} missing `handle` field"))
9425 };
9426 let a_handle = read_card_handle(a_home)
9427 .with_context(|| format!("session {a_name} (a): read agent-card.handle"))?;
9428 let b_handle = read_card_handle(b_home)
9429 .with_context(|| format!("session {b_name} (b): read agent-card.handle"))?;
9430
9431 run(a_home, &["add", b_name, "--local-sister", "--json"])
9435 .with_context(|| format!("step 1/8: {a_name} `wire add {b_name} --local-sister`"))?;
9436
9437 std::thread::sleep(Duration::from_secs(settle_secs));
9439
9440 run(b_home, &["pull", "--json"]).with_context(|| format!("step 4/8: {b_name} `wire pull`"))?;
9443 run(b_home, &["pair-accept", &a_handle, "--json"]).with_context(|| {
9444 format!("step 5/8: {b_name} `wire pair-accept {a_handle}` (a session={a_name})")
9445 })?;
9446 run(b_home, &["push", "--json"]).with_context(|| format!("step 6/8: {b_name} `wire push`"))?;
9447
9448 std::thread::sleep(Duration::from_secs(settle_secs));
9450
9451 run(a_home, &["pull", "--json"]).with_context(|| format!("step 8/8: {a_name} `wire pull`"))?;
9453 let _ = &b_handle;
9455
9456 Ok(())
9457}
9458
9459fn cmd_session_env(name_arg: Option<&str>, as_json: bool) -> Result<()> {
9460 let name = resolve_session_name(name_arg)?;
9461 let session_home = crate::session::session_dir(&name)?;
9462 if !session_home.exists() {
9463 bail!(
9464 "no session named {name:?} on this machine. `wire session list` to enumerate, \
9465 `wire session new {name}` to create."
9466 );
9467 }
9468 if as_json {
9469 println!(
9470 "{}",
9471 serde_json::to_string(&json!({
9472 "name": name,
9473 "home_dir": session_home.to_string_lossy(),
9474 "export": format!("export WIRE_HOME={}", session_home.to_string_lossy()),
9475 }))?
9476 );
9477 } else {
9478 println!("export WIRE_HOME={}", session_home.to_string_lossy());
9479 }
9480 Ok(())
9481}
9482
9483fn cmd_session_current(as_json: bool) -> Result<()> {
9484 let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
9485 let registry = crate::session::read_registry().unwrap_or_default();
9486 let cwd_key = cwd.to_string_lossy().into_owned();
9487 let name = registry.by_cwd.get(&cwd_key).cloned();
9488 if as_json {
9489 println!(
9490 "{}",
9491 serde_json::to_string(&json!({
9492 "cwd": cwd_key,
9493 "session": name,
9494 }))?
9495 );
9496 } else if let Some(n) = name {
9497 println!("{n}");
9498 } else {
9499 println!("(no session registered for this cwd)");
9500 }
9501 Ok(())
9502}
9503
9504fn cmd_session_destroy(name_arg: &str, force: bool, as_json: bool) -> Result<()> {
9505 let name = crate::session::sanitize_name(name_arg);
9506 let session_home = crate::session::session_dir(&name)?;
9507 if !session_home.exists() {
9508 if as_json {
9509 println!(
9510 "{}",
9511 serde_json::to_string(&json!({
9512 "name": name,
9513 "destroyed": false,
9514 "reason": "no such session",
9515 }))?
9516 );
9517 } else {
9518 println!("no session named {name:?} — nothing to destroy.");
9519 }
9520 return Ok(());
9521 }
9522 if !force {
9523 bail!(
9524 "destroying session {name:?} would delete its keypair + state irrecoverably. \
9525 Pass --force to confirm."
9526 );
9527 }
9528
9529 let pidfile = session_home.join("state").join("wire").join("daemon.pid");
9531 if let Ok(bytes) = std::fs::read(&pidfile) {
9532 let pid: Option<u32> = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) {
9533 v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32)
9534 } else {
9535 String::from_utf8_lossy(&bytes).trim().parse::<u32>().ok()
9536 };
9537 if let Some(p) = pid {
9538 let _ = std::process::Command::new("kill")
9539 .args(["-TERM", &p.to_string()])
9540 .output();
9541 }
9542 }
9543
9544 std::fs::remove_dir_all(&session_home)
9545 .with_context(|| format!("removing session dir {session_home:?}"))?;
9546
9547 let mut registry = crate::session::read_registry().unwrap_or_default();
9549 registry.by_cwd.retain(|_, v| v != &name);
9550 crate::session::write_registry(®istry)?;
9551
9552 if as_json {
9553 println!(
9554 "{}",
9555 serde_json::to_string(&json!({
9556 "name": name,
9557 "destroyed": true,
9558 }))?
9559 );
9560 } else {
9561 println!("destroyed session {name:?}.");
9562 }
9563 Ok(())
9564}
9565
9566fn cmd_diag(action: DiagAction) -> Result<()> {
9569 let state = config::state_dir()?;
9570 let knob = state.join("diag.enabled");
9571 let log_path = state.join("diag.jsonl");
9572 match action {
9573 DiagAction::Tail { limit, json } => {
9574 let entries = crate::diag::tail(limit);
9575 if json {
9576 for e in entries {
9577 println!("{}", serde_json::to_string(&e)?);
9578 }
9579 } else if entries.is_empty() {
9580 println!("wire diag: no entries (diag may be disabled — `wire diag enable`)");
9581 } else {
9582 for e in entries {
9583 let ts = e["ts"].as_u64().unwrap_or(0);
9584 let ty = e["type"].as_str().unwrap_or("?");
9585 let pid = e["pid"].as_u64().unwrap_or(0);
9586 let payload = e["payload"].to_string();
9587 println!("[{ts}] pid={pid} {ty} {payload}");
9588 }
9589 }
9590 }
9591 DiagAction::Enable => {
9592 config::ensure_dirs()?;
9593 std::fs::write(&knob, "1")?;
9594 println!("wire diag: enabled at {knob:?}");
9595 }
9596 DiagAction::Disable => {
9597 if knob.exists() {
9598 std::fs::remove_file(&knob)?;
9599 }
9600 println!("wire diag: disabled (env WIRE_DIAG may still flip it on per-process)");
9601 }
9602 DiagAction::Status { json } => {
9603 let enabled = crate::diag::is_enabled();
9604 let size = std::fs::metadata(&log_path).map(|m| m.len()).unwrap_or(0);
9605 if json {
9606 println!(
9607 "{}",
9608 serde_json::to_string(&serde_json::json!({
9609 "enabled": enabled,
9610 "log_path": log_path,
9611 "log_size_bytes": size,
9612 }))?
9613 );
9614 } else {
9615 println!("wire diag status");
9616 println!(" enabled: {enabled}");
9617 println!(" log: {log_path:?}");
9618 println!(" log size: {size} bytes");
9619 }
9620 }
9621 }
9622 Ok(())
9623}
9624
9625fn cmd_service(action: ServiceAction) -> Result<()> {
9628 let kind = |local_relay: bool| {
9629 if local_relay {
9630 crate::service::ServiceKind::LocalRelay
9631 } else {
9632 crate::service::ServiceKind::Daemon
9633 }
9634 };
9635 let (report, as_json) = match action {
9636 ServiceAction::Install { local_relay, json } => {
9637 (crate::service::install_kind(kind(local_relay))?, json)
9638 }
9639 ServiceAction::Uninstall { local_relay, json } => {
9640 (crate::service::uninstall_kind(kind(local_relay))?, json)
9641 }
9642 ServiceAction::Status { local_relay, json } => {
9643 (crate::service::status_kind(kind(local_relay))?, json)
9644 }
9645 };
9646 if as_json {
9647 println!("{}", serde_json::to_string(&report)?);
9648 } else {
9649 println!("wire service {}", report.action);
9650 println!(" platform: {}", report.platform);
9651 println!(" unit: {}", report.unit_path);
9652 println!(" status: {}", report.status);
9653 println!(" detail: {}", report.detail);
9654 }
9655 Ok(())
9656}
9657
9658fn upgrade_kill_set(
9679 my_pid: Option<u32>,
9680 found_daemon_pids: &[u32],
9681 owned_session_pids: &std::collections::HashSet<u32>,
9682) -> Vec<u32> {
9683 let mut k: Vec<u32> = Vec::new();
9684 if let Some(p) = my_pid {
9685 k.push(p);
9686 }
9687 for &p in found_daemon_pids {
9688 if !owned_session_pids.contains(&p) && Some(p) != my_pid {
9689 k.push(p); }
9691 }
9692 k.sort_unstable();
9693 k.dedup();
9694 k
9695}
9696
9697#[cfg(test)]
9698mod upgrade_tests {
9699 use super::*;
9700 use std::collections::HashSet;
9701
9702 #[test]
9703 fn upgrade_kill_set_is_session_scoped() {
9704 let owned: HashSet<u32> = [100, 200].into_iter().collect();
9706 let k = upgrade_kill_set(Some(100), &[100, 200, 999], &owned);
9708 assert!(k.contains(&100), "must kill my own daemon (to replace it)");
9709 assert!(k.contains(&999), "must sweep a true orphan");
9710 assert!(!k.contains(&200), "must SPARE a sibling session's daemon");
9711
9712 assert_eq!(
9716 upgrade_kill_set(Some(100), &[], &owned),
9717 vec![100],
9718 "own daemon killed even when the process scan is empty"
9719 );
9720
9721 assert_eq!(upgrade_kill_set(None, &[999], &HashSet::new()), vec![999]);
9723 }
9724}
9725
9726fn cmd_upgrade(check_only: bool, as_json: bool) -> Result<()> {
9727 let daemon_pids: Vec<u32> = crate::platform::find_processes_by_cmdline("wire daemon");
9736 let relay_pids: Vec<u32> = crate::platform::find_processes_by_cmdline("wire relay-server");
9737 let running_pids: Vec<u32> = daemon_pids
9738 .iter()
9739 .chain(relay_pids.iter())
9740 .copied()
9741 .collect();
9742
9743 let record = crate::ensure_up::read_pid_record("daemon");
9745 let recorded_version: Option<String> = match &record {
9746 crate::ensure_up::PidRecord::Json(d) => Some(d.version.clone()),
9747 crate::ensure_up::PidRecord::LegacyInt(_) => Some("<pre-0.5.11>".to_string()),
9748 _ => None,
9749 };
9750 let cli_version = env!("CARGO_PKG_VERSION").to_string();
9751
9752 let my_daemon_pid = record.pid();
9766 let owned_session_pids: std::collections::HashSet<u32> = crate::session::list_sessions()
9767 .unwrap_or_default()
9768 .iter()
9769 .filter_map(|s| crate::session::session_daemon_pid(&s.home_dir))
9770 .collect();
9771 let kill_set = upgrade_kill_set(my_daemon_pid, &daemon_pids, &owned_session_pids);
9772 if check_only {
9775 let sessions_with_daemons: Vec<String> = crate::session::list_sessions()
9777 .unwrap_or_default()
9778 .iter()
9779 .filter(|s| s.daemon_running)
9780 .map(|s| s.name.clone())
9781 .collect();
9782 let mut path_dupes: Vec<String> = Vec::new();
9783 if let Ok(path) = std::env::var("PATH") {
9784 let mut seen: std::collections::HashSet<std::path::PathBuf> =
9785 std::collections::HashSet::new();
9786 for dir in path.split(':') {
9787 let candidate = std::path::PathBuf::from(dir).join("wire");
9788 if candidate.exists() {
9789 let canon = candidate.canonicalize().unwrap_or(candidate);
9790 if seen.insert(canon.clone()) {
9791 path_dupes.push(canon.to_string_lossy().into_owned());
9792 }
9793 }
9794 }
9795 }
9796 let installed_service_kinds: Vec<&'static str> = [
9799 (crate::service::ServiceKind::Daemon, "daemon"),
9800 (crate::service::ServiceKind::LocalRelay, "local-relay"),
9801 ]
9802 .into_iter()
9803 .filter_map(|(k, label)| {
9804 crate::service::status_kind(k)
9805 .ok()
9806 .filter(|r| r.status != "absent")
9807 .map(|_| label)
9808 })
9809 .collect();
9810 let report = json!({
9811 "running_pids": running_pids,
9812 "running_daemons": daemon_pids,
9813 "running_relay_servers": relay_pids,
9814 "pidfile_version": recorded_version,
9815 "cli_version": cli_version,
9816 "would_kill": kill_set,
9817 "would_refresh_services": installed_service_kinds,
9818 "session_daemons_running": sessions_with_daemons,
9819 "path_binaries": path_dupes,
9820 "path_duplicate_warning": path_dupes.len() > 1,
9821 });
9822 if as_json {
9823 println!("{}", serde_json::to_string(&report)?);
9824 } else {
9825 println!("wire upgrade --check");
9826 println!(" cli version: {cli_version}");
9827 println!(
9828 " pidfile version: {}",
9829 recorded_version.as_deref().unwrap_or("(missing)")
9830 );
9831 if running_pids.is_empty() {
9832 println!(" running daemons: none");
9833 println!(" running relays: none");
9834 } else {
9835 if daemon_pids.is_empty() {
9836 println!(" running daemons: none");
9837 } else {
9838 let p: Vec<String> = daemon_pids.iter().map(|p| p.to_string()).collect();
9839 println!(" running daemons: pids {}", p.join(", "));
9840 }
9841 if relay_pids.is_empty() {
9842 println!(" running relays: none");
9843 } else {
9844 let p: Vec<String> = relay_pids.iter().map(|p| p.to_string()).collect();
9845 println!(" running relays: pids {}", p.join(", "));
9846 }
9847 println!(" would kill all + spawn fresh");
9848 }
9849 if !installed_service_kinds.is_empty() {
9850 println!(
9851 " would refresh: {} installed service unit(s) → new binary path",
9852 installed_service_kinds.join(", ")
9853 );
9854 }
9855 if !sessions_with_daemons.is_empty() {
9856 println!(
9857 " session daemons: {} (would respawn under new binary)",
9858 sessions_with_daemons.join(", ")
9859 );
9860 }
9861 if path_dupes.len() > 1 {
9862 println!(
9863 " PATH warning: {} distinct `wire` binaries on PATH:",
9864 path_dupes.len()
9865 );
9866 for b in &path_dupes {
9867 println!(" {b}");
9868 }
9869 println!(" operators should remove the stale ones");
9870 }
9871 }
9872 return Ok(());
9873 }
9874
9875 for pid in &kill_set {
9887 let _ = crate::platform::kill_process(*pid, false); }
9889 if !kill_set.is_empty() {
9890 let deadline = std::time::Instant::now() + std::time::Duration::from_millis(800);
9892 while std::time::Instant::now() < deadline && kill_set.iter().any(|p| process_alive_pid(*p))
9893 {
9894 std::thread::sleep(std::time::Duration::from_millis(50));
9895 }
9896 for pid in &kill_set {
9899 if process_alive_pid(*pid) {
9900 let _ = crate::platform::kill_process(*pid, true);
9901 }
9902 }
9903 std::thread::sleep(std::time::Duration::from_millis(200)); }
9905 let killed: Vec<u32> = kill_set
9907 .iter()
9908 .copied()
9909 .filter(|p| !process_alive_pid(*p))
9910 .collect();
9911
9912 let pidfile = config::state_dir()?.join("daemon.pid");
9915 if pidfile.exists() {
9916 let _ = std::fs::remove_file(&pidfile);
9917 }
9918
9919 let mut path_dupes: Vec<String> = Vec::new();
9931 if let Ok(path) = std::env::var("PATH") {
9932 let mut seen: std::collections::HashSet<std::path::PathBuf> =
9933 std::collections::HashSet::new();
9934 for dir in path.split(':') {
9935 let candidate = std::path::PathBuf::from(dir).join("wire");
9936 if candidate.exists() {
9937 let canon = candidate.canonicalize().unwrap_or(candidate);
9938 if seen.insert(canon.clone()) {
9939 path_dupes.push(canon.to_string_lossy().into_owned());
9940 }
9941 }
9942 }
9943 }
9944 let path_warning = if path_dupes.len() > 1 {
9945 Some(format!(
9946 "WARN: {} distinct `wire` binaries on PATH — old versions can shadow the fresh install:\n {}",
9947 path_dupes.len(),
9948 path_dupes.join("\n ")
9949 ))
9950 } else {
9951 None
9952 };
9953
9954 let mut service_refreshes: Vec<Value> = Vec::new();
9968 for kind in [
9969 crate::service::ServiceKind::Daemon,
9970 crate::service::ServiceKind::LocalRelay,
9971 ] {
9972 let already_installed = crate::service::status_kind(kind)
9973 .map(|r| r.status != "absent")
9974 .unwrap_or(false);
9975 if !already_installed {
9976 continue;
9977 }
9978 match crate::service::install_kind(kind) {
9979 Ok(rep) => service_refreshes.push(json!({
9980 "kind": rep.kind,
9981 "platform": rep.platform,
9982 "status": rep.status,
9983 "unit_path": rep.unit_path,
9984 "action": "refreshed",
9985 })),
9986 Err(e) => service_refreshes.push(json!({
9987 "kind": format!("{kind:?}"),
9988 "action": "refresh_failed",
9989 "error": format!("{e:#}"),
9990 })),
9991 }
9992 }
9993
9994 let spawned = crate::ensure_up::ensure_daemon_running()?;
10000
10001 let session_respawns: Vec<Value> = Vec::new();
10006
10007 let new_record = crate::ensure_up::read_pid_record("daemon");
10008 let new_pid = new_record.pid();
10009 let new_version: Option<String> = if let crate::ensure_up::PidRecord::Json(d) = &new_record {
10010 Some(d.version.clone())
10011 } else {
10012 None
10013 };
10014
10015 if as_json {
10016 println!(
10017 "{}",
10018 serde_json::to_string(&json!({
10019 "killed": killed,
10020 "found_daemons": daemon_pids,
10021 "spared_relay_servers": relay_pids,
10022 "service_refreshes": service_refreshes,
10023 "spawned_fresh_daemon": spawned,
10024 "new_pid": new_pid,
10025 "new_version": new_version,
10026 "cli_version": cli_version,
10027 "session_respawns": session_respawns,
10028 "path_binaries": path_dupes,
10029 "path_warning": path_warning,
10030 }))?
10031 );
10032 } else {
10033 if killed.is_empty() {
10034 println!("wire upgrade: no stale wire processes running");
10035 } else {
10036 let killed_list = killed
10037 .iter()
10038 .map(|p| p.to_string())
10039 .collect::<Vec<_>>()
10040 .join(", ");
10041 if relay_pids.is_empty() {
10046 println!(
10047 "wire upgrade: killed {} daemon(s) [{killed_list}]",
10048 killed.len()
10049 );
10050 } else {
10051 let relay_list = relay_pids
10052 .iter()
10053 .map(|p| p.to_string())
10054 .collect::<Vec<_>>()
10055 .join(", ");
10056 println!(
10057 "wire upgrade: killed {} daemon(s) [{killed_list}]; spared {} shared relay-server(s) [{relay_list}]",
10058 killed.len(),
10059 relay_pids.len()
10060 );
10061 }
10062 }
10063 if !service_refreshes.is_empty() {
10064 println!(
10065 "wire upgrade: refreshed {} installed service unit(s) to point at the new binary:",
10066 service_refreshes.len()
10067 );
10068 for r in &service_refreshes {
10069 let kind = r.get("kind").and_then(Value::as_str).unwrap_or("?");
10070 let action = r.get("action").and_then(Value::as_str).unwrap_or("?");
10071 let status = r.get("status").and_then(Value::as_str).unwrap_or("");
10072 let platform = r.get("platform").and_then(Value::as_str).unwrap_or("");
10073 if action == "refreshed" {
10074 println!(" - {kind}: {action} ({status}, {platform})");
10075 } else {
10076 let err = r.get("error").and_then(Value::as_str).unwrap_or("");
10077 println!(" - {kind}: {action} ({err})");
10078 }
10079 }
10080 }
10081 if spawned {
10082 println!(
10083 "wire upgrade: spawned fresh daemon (pid {} v{})",
10084 new_pid
10085 .map(|p| p.to_string())
10086 .unwrap_or_else(|| "?".to_string()),
10087 new_version.as_deref().unwrap_or(&cli_version),
10088 );
10089 } else {
10090 println!("wire upgrade: daemon was already running on current binary");
10091 }
10092 if !session_respawns.is_empty() {
10093 println!(
10094 "wire upgrade: refreshed {} session daemon(s):",
10095 session_respawns.len()
10096 );
10097 for r in &session_respawns {
10098 let h = r["session_home"].as_str().unwrap_or("?");
10099 let s = r["status"].as_str().unwrap_or("?");
10100 let label = std::path::Path::new(h)
10101 .file_name()
10102 .map(|f| f.to_string_lossy().into_owned())
10103 .unwrap_or_else(|| h.to_string());
10104 println!(" {label:<24} {s}");
10105 }
10106 }
10107 if let Some(msg) = &path_warning {
10108 eprintln!("wire upgrade: {msg}");
10109 }
10110 }
10111 Ok(())
10112}
10113
10114fn json_default(explicit: bool) -> bool {
10124 if explicit {
10125 return true;
10126 }
10127 if std::env::var("WIRE_NO_AUTO_JSON").is_ok() {
10128 return false;
10129 }
10130 use std::io::IsTerminal;
10131 !std::io::stdout().is_terminal()
10132}
10133
10134fn process_alive_pid(pid: u32) -> bool {
10135 crate::platform::process_alive(pid)
10140}
10141
10142fn levenshtein_ci(a: &str, b: &str) -> usize {
10148 let a: Vec<char> = a.to_ascii_lowercase().chars().collect();
10149 let b: Vec<char> = b.to_ascii_lowercase().chars().collect();
10150 let (a, b) = if a.len() < b.len() { (a, b) } else { (b, a) };
10151 let (m, n) = (a.len(), b.len());
10152 if m == 0 {
10153 return n;
10154 }
10155 let mut prev: Vec<usize> = (0..=m).collect();
10156 let mut curr = vec![0usize; m + 1];
10157 for j in 1..=n {
10158 curr[0] = j;
10159 for i in 1..=m {
10160 let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
10161 curr[i] = std::cmp::min(
10162 std::cmp::min(curr[i - 1] + 1, prev[i] + 1),
10163 prev[i - 1] + cost,
10164 );
10165 }
10166 std::mem::swap(&mut prev, &mut curr);
10167 }
10168 prev[m]
10169}
10170
10171pub fn closest_candidates(
10175 needle: &str,
10176 pool: &[String],
10177 max_distance: usize,
10178 max_results: usize,
10179) -> Vec<String> {
10180 let mut scored: Vec<(usize, &String)> = pool
10181 .iter()
10182 .map(|c| (levenshtein_ci(needle, c), c))
10183 .filter(|(d, _)| *d <= max_distance)
10184 .collect();
10185 scored.sort_by_key(|(d, _)| *d);
10186 scored
10187 .into_iter()
10188 .take(max_results)
10189 .map(|(_, c)| c.clone())
10190 .collect()
10191}
10192
10193fn known_local_names() -> Vec<String> {
10198 let mut names: Vec<String> = Vec::new();
10199 if let Ok(trust) = config::read_trust() {
10200 if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
10206 for (handle, agent) in agents {
10207 names.push(handle.clone());
10208 if let Some(did) = agent.get("did").and_then(Value::as_str) {
10209 let ch = crate::character::Character::from_did(did);
10210 names.push(ch.nickname);
10211 }
10212 }
10213 }
10214 }
10215 if let Ok(sessions) = crate::session::list_sessions() {
10216 for s in sessions {
10217 names.push(s.name.clone());
10218 if let Some(h) = &s.handle {
10219 names.push(h.clone());
10220 }
10221 if let Some(ch) = &s.character {
10222 names.push(ch.nickname.clone());
10223 }
10224 }
10225 }
10226 names.sort();
10227 names.dedup();
10228 names
10229}
10230
10231fn deprecation_warn(verb: &str, replacement: &str, json_mode: bool) {
10239 if json_mode {
10240 return;
10241 }
10242 let key = format!("WIRE_DEPRECATION_NAGGED_{}", verb.replace('-', "_"));
10250 if std::env::var(&key).is_ok() {
10251 return;
10252 }
10253 unsafe {
10257 std::env::set_var(&key, "1");
10258 }
10259 eprintln!(
10260 "wire {verb}: DEPRECATED in v0.9 — use `wire {replacement}`. \
10261 Will be removed in v1.0 (target 2026-Q3). \
10262 Suppress: set WIRE_DEPRECATION_NAGGED_{}=1.",
10263 verb.replace('-', "_")
10264 );
10265}
10266
10267#[derive(Clone, Debug, serde::Serialize)]
10271pub struct DoctorCheck {
10272 pub id: String,
10275 pub status: String,
10277 pub detail: String,
10279 #[serde(skip_serializing_if = "Option::is_none")]
10281 pub fix: Option<String>,
10282}
10283
10284impl DoctorCheck {
10285 fn pass(id: &str, detail: impl Into<String>) -> Self {
10286 Self {
10287 id: id.into(),
10288 status: "PASS".into(),
10289 detail: detail.into(),
10290 fix: None,
10291 }
10292 }
10293 fn warn(id: &str, detail: impl Into<String>, fix: impl Into<String>) -> Self {
10294 Self {
10295 id: id.into(),
10296 status: "WARN".into(),
10297 detail: detail.into(),
10298 fix: Some(fix.into()),
10299 }
10300 }
10301 fn fail(id: &str, detail: impl Into<String>, fix: impl Into<String>) -> Self {
10302 Self {
10303 id: id.into(),
10304 status: "FAIL".into(),
10305 detail: detail.into(),
10306 fix: Some(fix.into()),
10307 }
10308 }
10309}
10310
10311fn cmd_doctor(as_json: bool, recent_rejections: usize) -> Result<()> {
10316 let checks: Vec<DoctorCheck> = vec![
10317 check_daemon_health(),
10318 check_daemon_pid_consistency(),
10319 check_relay_reachable(),
10320 check_pair_rejections(recent_rejections),
10321 check_cursor_progress(),
10322 ];
10323
10324 let fails = checks.iter().filter(|c| c.status == "FAIL").count();
10325 let warns = checks.iter().filter(|c| c.status == "WARN").count();
10326
10327 if as_json {
10328 println!(
10329 "{}",
10330 serde_json::to_string(&json!({
10331 "checks": checks,
10332 "fail_count": fails,
10333 "warn_count": warns,
10334 "ok": fails == 0,
10335 }))?
10336 );
10337 } else {
10338 println!("wire doctor — {} checks", checks.len());
10339 for c in &checks {
10340 let bullet = match c.status.as_str() {
10341 "PASS" => "✓",
10342 "WARN" => "!",
10343 "FAIL" => "✗",
10344 _ => "?",
10345 };
10346 println!(" {bullet} [{}] {}: {}", c.status, c.id, c.detail);
10347 if let Some(fix) = &c.fix {
10348 println!(" fix: {fix}");
10349 }
10350 }
10351 println!();
10352 if fails == 0 && warns == 0 {
10353 println!("ALL GREEN");
10354 } else {
10355 println!("{fails} FAIL, {warns} WARN");
10356 }
10357 }
10358
10359 if fails > 0 {
10360 std::process::exit(1);
10361 }
10362 Ok(())
10363}
10364
10365fn check_daemon_health() -> DoctorCheck {
10372 let snap = crate::ensure_up::daemon_liveness();
10378 let pgrep_pids = &snap.pgrep_pids;
10379 let pidfile_pid = snap.pidfile_pid;
10380 let pidfile_alive = snap.pidfile_alive;
10381 let orphan_pids = &snap.orphan_pids;
10382
10383 let fmt_pids = |xs: &[u32]| -> String {
10384 xs.iter()
10385 .map(|p| p.to_string())
10386 .collect::<Vec<_>>()
10387 .join(", ")
10388 };
10389
10390 match (pgrep_pids.len(), pidfile_alive, orphan_pids.is_empty()) {
10391 (0, _, _) => DoctorCheck::fail(
10392 "daemon",
10393 "no `wire daemon` process running — nothing pulling inbox or pushing outbox",
10394 "`wire daemon &` to start, or re-run `wire up <handle>@<relay>` to bootstrap",
10395 ),
10396 (1, true, true) => DoctorCheck::pass(
10398 "daemon",
10399 format!(
10400 "one daemon running (pid {}, matches pidfile)",
10401 pgrep_pids[0]
10402 ),
10403 ),
10404 (n, true, false) => DoctorCheck::fail(
10406 "daemon",
10407 format!(
10408 "{n} `wire daemon` processes running (pids: {}); pidfile claims pid {} but pgrep also sees orphan(s): {}. \
10409 The orphans race the relay cursor — they advance past events your current binary can't process. \
10410 (Issue #2 exact class.)",
10411 fmt_pids(pgrep_pids),
10412 pidfile_pid.unwrap(),
10413 fmt_pids(orphan_pids),
10414 ),
10415 "`wire upgrade` kills all orphans and spawns a fresh daemon with a clean pidfile",
10416 ),
10417 (n, false, _) => DoctorCheck::fail(
10419 "daemon",
10420 format!(
10421 "{n} `wire daemon` process(es) running (pids: {}) but pidfile {} — \
10422 every running daemon is an orphan, advancing the cursor without coordinating with the current CLI. \
10423 (Issue #2 exact class: doctor previously PASSed this state while `wire status` said DOWN.)",
10424 fmt_pids(pgrep_pids),
10425 match pidfile_pid {
10426 Some(p) => format!("claims pid {p} which is dead"),
10427 None => "is missing".to_string(),
10428 },
10429 ),
10430 "`wire upgrade` to kill the orphan(s) and spawn a fresh daemon",
10431 ),
10432 (n, true, true) => DoctorCheck::warn(
10434 "daemon",
10435 format!(
10436 "{n} `wire daemon` processes running (pids: {}). Multiple daemons race the relay cursor.",
10437 fmt_pids(pgrep_pids)
10438 ),
10439 "kill all-but-one: `pkill -f \"wire daemon\"; wire daemon &`",
10440 ),
10441 }
10442}
10443
10444fn check_daemon_pid_consistency() -> DoctorCheck {
10456 let snap = crate::ensure_up::daemon_liveness();
10457 match &snap.record {
10458 crate::ensure_up::PidRecord::Missing => DoctorCheck::pass(
10459 "daemon_pid_consistency",
10460 "no daemon.pid yet — fresh box or daemon never started",
10461 ),
10462 crate::ensure_up::PidRecord::Corrupt(reason) => DoctorCheck::warn(
10463 "daemon_pid_consistency",
10464 format!("daemon.pid is corrupt: {reason}"),
10465 "delete state/wire/daemon.pid; next `wire daemon &` will rewrite",
10466 ),
10467 crate::ensure_up::PidRecord::LegacyInt(pid) => {
10468 let pid = *pid;
10471 if !crate::ensure_up::pid_is_alive(pid) {
10472 return DoctorCheck::warn(
10473 "daemon_pid_consistency",
10474 format!(
10475 "daemon.pid (legacy-int) points at pid {pid} which is not running. \
10476 Stale pidfile from a crashed pre-0.5.11 daemon. \
10477 (Issue #2: this surface used to PASS while `wire status` said DOWN.)"
10478 ),
10479 "`wire upgrade` (kills any orphan + spawns a fresh daemon with JSON pidfile)",
10480 );
10481 }
10482 DoctorCheck::warn(
10483 "daemon_pid_consistency",
10484 format!(
10485 "daemon.pid is legacy-int form (pid={pid}, no version/bin_path metadata). \
10486 Daemon was started by a pre-0.5.11 binary."
10487 ),
10488 "run `wire upgrade` to kill the old daemon and start a fresh one with the JSON pidfile",
10489 )
10490 }
10491 crate::ensure_up::PidRecord::Json(d) => {
10492 if !snap.pidfile_alive {
10496 return DoctorCheck::warn(
10497 "daemon_pid_consistency",
10498 format!(
10499 "daemon.pid records pid {pid} (v{version}) but that process is not running — \
10500 pidfile is stale. `wire status` will report DOWN, but pre-v0.5.19 doctor \
10501 silently PASSed this state and ignored any live orphan daemons (#2 root cause).",
10502 pid = d.pid,
10503 version = d.version,
10504 ),
10505 "`wire upgrade` to clean up the stale pidfile + spawn a fresh daemon \
10506 (kills any orphan daemon advancing the cursor without coordination)",
10507 );
10508 }
10509 let mut issues: Vec<String> = Vec::new();
10510 if d.schema != crate::ensure_up::DAEMON_PID_SCHEMA {
10511 issues.push(format!(
10512 "schema={} (expected {})",
10513 d.schema,
10514 crate::ensure_up::DAEMON_PID_SCHEMA
10515 ));
10516 }
10517 let cli_version = env!("CARGO_PKG_VERSION");
10518 if d.version != cli_version {
10519 issues.push(format!("version daemon={} cli={cli_version}", d.version));
10520 }
10521 if !std::path::Path::new(&d.bin_path).exists() {
10522 issues.push(format!("bin_path {} missing on disk", d.bin_path));
10523 }
10524 if let Ok(card) = config::read_agent_card()
10526 && let Some(current_did) = card.get("did").and_then(Value::as_str)
10527 && let Some(recorded_did) = &d.did
10528 && recorded_did != current_did
10529 {
10530 issues.push(format!(
10531 "did daemon={recorded_did} config={current_did} — identity drift"
10532 ));
10533 }
10534 if let Ok(state) = config::read_relay_state()
10535 && let Some(current_relay) = state
10536 .get("self")
10537 .and_then(|s| s.get("relay_url"))
10538 .and_then(Value::as_str)
10539 && let Some(recorded_relay) = &d.relay_url
10540 && recorded_relay != current_relay
10541 {
10542 issues.push(format!(
10543 "relay_url daemon={recorded_relay} config={current_relay} — relay-migration drift"
10544 ));
10545 }
10546 if issues.is_empty() {
10547 DoctorCheck::pass(
10548 "daemon_pid_consistency",
10549 format!(
10550 "daemon v{} bound to {} as {}",
10551 d.version,
10552 d.relay_url.as_deref().unwrap_or("?"),
10553 d.did.as_deref().unwrap_or("?")
10554 ),
10555 )
10556 } else {
10557 DoctorCheck::warn(
10558 "daemon_pid_consistency",
10559 format!("daemon pidfile drift: {}", issues.join("; ")),
10560 "`wire upgrade` to atomically restart daemon with current config".to_string(),
10561 )
10562 }
10563 }
10564 }
10565}
10566
10567fn check_relay_reachable() -> DoctorCheck {
10569 let state = match config::read_relay_state() {
10570 Ok(s) => s,
10571 Err(e) => {
10572 return DoctorCheck::fail(
10573 "relay",
10574 format!("could not read relay state: {e}"),
10575 "run `wire up <handle>@<relay>` to bootstrap",
10576 );
10577 }
10578 };
10579 let url = state
10580 .get("self")
10581 .and_then(|s| s.get("relay_url"))
10582 .and_then(Value::as_str)
10583 .unwrap_or("");
10584 if url.is_empty() {
10585 return DoctorCheck::warn(
10586 "relay",
10587 "no relay bound — wire send/pull will not work",
10588 "run `wire bind-relay <url>` or `wire up <handle>@<relay>`",
10589 );
10590 }
10591 let client = crate::relay_client::RelayClient::new(url);
10592 match client.check_healthz() {
10593 Ok(()) => DoctorCheck::pass("relay", format!("{url} healthz=200")),
10594 Err(e) => DoctorCheck::fail(
10595 "relay",
10596 format!("{url} unreachable: {e}"),
10597 format!("network reachable to {url}? relay running? check `curl {url}/healthz`"),
10598 ),
10599 }
10600}
10601
10602fn check_pair_rejections(recent_n: usize) -> DoctorCheck {
10606 let path = match config::state_dir() {
10607 Ok(d) => d.join("pair-rejected.jsonl"),
10608 Err(e) => {
10609 return DoctorCheck::warn(
10610 "pair_rejections",
10611 format!("could not resolve state dir: {e}"),
10612 "set WIRE_HOME or fix XDG_STATE_HOME",
10613 );
10614 }
10615 };
10616 if !path.exists() {
10617 return DoctorCheck::pass(
10618 "pair_rejections",
10619 "no pair-rejected.jsonl — no recorded pair failures",
10620 );
10621 }
10622 let body = match std::fs::read_to_string(&path) {
10623 Ok(b) => b,
10624 Err(e) => {
10625 return DoctorCheck::warn(
10626 "pair_rejections",
10627 format!("could not read {path:?}: {e}"),
10628 "check file permissions",
10629 );
10630 }
10631 };
10632 let lines: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
10633 if lines.is_empty() {
10634 return DoctorCheck::pass("pair_rejections", "pair-rejected.jsonl present but empty");
10635 }
10636 let total = lines.len();
10637 let recent: Vec<&str> = lines.iter().rev().take(recent_n).rev().copied().collect();
10638 let mut summary: Vec<String> = Vec::new();
10639 for line in &recent {
10640 if let Ok(rec) = serde_json::from_str::<Value>(line) {
10641 let peer = rec.get("peer").and_then(Value::as_str).unwrap_or("?");
10642 let code = rec.get("code").and_then(Value::as_str).unwrap_or("?");
10643 summary.push(format!("{peer}/{code}"));
10644 }
10645 }
10646 DoctorCheck::warn(
10647 "pair_rejections",
10648 format!(
10649 "{total} pair failures recorded. recent: [{}]",
10650 summary.join(", ")
10651 ),
10652 format!(
10653 "inspect {path:?} for full details. Each entry is a pair-flow error that previously silently dropped — re-run `wire pair <handle>@<relay>` to retry."
10654 ),
10655 )
10656}
10657
10658fn check_cursor_progress() -> DoctorCheck {
10663 let state = match config::read_relay_state() {
10664 Ok(s) => s,
10665 Err(e) => {
10666 return DoctorCheck::warn(
10667 "cursor",
10668 format!("could not read relay state: {e}"),
10669 "check ~/Library/Application Support/wire/relay.json",
10670 );
10671 }
10672 };
10673 let cursor = state
10674 .get("self")
10675 .and_then(|s| s.get("last_pulled_event_id"))
10676 .and_then(Value::as_str)
10677 .map(|s| s.chars().take(16).collect::<String>())
10678 .unwrap_or_else(|| "<none>".to_string());
10679 DoctorCheck::pass(
10680 "cursor",
10681 format!(
10682 "current cursor: {cursor}. P0.1 cursor blocking is active — see `wire pull --json` for cursor_blocked / rejected[].blocks_cursor entries."
10683 ),
10684 )
10685}
10686
10687#[cfg(test)]
10688mod doctor_tests {
10689 use super::*;
10690
10691 #[test]
10692 fn doctor_check_constructors_set_status_correctly() {
10693 let p = DoctorCheck::pass("x", "ok");
10698 assert_eq!(p.status, "PASS");
10699 assert_eq!(p.fix, None);
10700
10701 let w = DoctorCheck::warn("x", "watch out", "do this");
10702 assert_eq!(w.status, "WARN");
10703 assert_eq!(w.fix, Some("do this".to_string()));
10704
10705 let f = DoctorCheck::fail("x", "broken", "fix it");
10706 assert_eq!(f.status, "FAIL");
10707 assert_eq!(f.fix, Some("fix it".to_string()));
10708 }
10709
10710 #[test]
10711 fn check_pair_rejections_no_file_is_pass() {
10712 config::test_support::with_temp_home(|| {
10715 config::ensure_dirs().unwrap();
10716 let c = check_pair_rejections(5);
10717 assert_eq!(c.status, "PASS", "no file should be PASS, got {c:?}");
10718 });
10719 }
10720
10721 #[test]
10722 fn check_pair_rejections_with_entries_warns() {
10723 config::test_support::with_temp_home(|| {
10727 config::ensure_dirs().unwrap();
10728 crate::pair_invite::record_pair_rejection(
10729 "willard",
10730 "pair_drop_ack_send_failed",
10731 "POST 502",
10732 );
10733 let c = check_pair_rejections(5);
10734 assert_eq!(c.status, "WARN");
10735 assert!(c.detail.contains("1 pair failures"));
10736 assert!(c.detail.contains("willard/pair_drop_ack_send_failed"));
10737 });
10738 }
10739}
10740
10741fn cmd_up(
10753 relay_arg: Option<&str>,
10754 name: Option<&str>,
10755 with_local: Option<&str>,
10756 no_local: bool,
10757 as_json: bool,
10758) -> Result<()> {
10759 let relay_url = match relay_arg {
10763 Some(r) => {
10764 let r = r.trim_start_matches('@');
10765 if r.starts_with("http://") || r.starts_with("https://") {
10766 r.to_string()
10767 } else {
10768 format!("https://{r}")
10769 }
10770 }
10771 None => crate::pair_invite::DEFAULT_RELAY.to_string(),
10772 };
10773
10774 let mut report: Vec<(String, String)> = Vec::new();
10775 let mut step = |stage: &str, detail: String| {
10776 report.push((stage.to_string(), detail.clone()));
10777 if !as_json {
10778 eprintln!("wire up: {stage} — {detail}");
10779 }
10780 };
10781
10782 if config::is_initialized()? {
10785 step("init", "already initialized".to_string());
10786 } else {
10787 cmd_init(
10788 None,
10789 name,
10790 Some(&relay_url),
10791 false,
10792 false,
10793 )?;
10794 step("init", format!("created identity bound to {relay_url}"));
10795 }
10796
10797 let canonical = {
10799 let card = config::read_agent_card()?;
10800 let did = card.get("did").and_then(Value::as_str).unwrap_or("");
10801 crate::agent_card::display_handle_from_did(did).to_string()
10802 };
10803 step("identity", format!("persona is `{canonical}`"));
10804
10805 let relay_state = config::read_relay_state()?;
10809 let bound_relay = relay_state
10810 .get("self")
10811 .and_then(|s| s.get("relay_url"))
10812 .and_then(Value::as_str)
10813 .unwrap_or("")
10814 .to_string();
10815 if bound_relay.is_empty() {
10816 cmd_bind_relay(
10820 &relay_url, None, false, false, false,
10822 )?;
10823 step("bind-relay", format!("bound to {relay_url}"));
10824 } else if bound_relay != relay_url {
10825 step(
10826 "bind-relay",
10827 format!(
10828 "WARNING: identity bound to {bound_relay} but you specified {relay_url}. \
10829 Keeping existing binding. Run `wire bind-relay {relay_url}` to switch."
10830 ),
10831 );
10832 } else {
10833 step("bind-relay", format!("already bound to {bound_relay}"));
10834 }
10835
10836 match cmd_claim(
10839 &canonical,
10840 Some(&relay_url),
10841 None,
10842 false,
10843 false,
10844 ) {
10845 Ok(()) => step(
10846 "claim",
10847 format!("{canonical}@{} claimed", strip_proto(&relay_url)),
10848 ),
10849 Err(e) => step(
10850 "claim",
10851 format!("WARNING: claim failed: {e}. You can retry `wire claim {canonical}`."),
10852 ),
10853 }
10854
10855 if no_local {
10860 step("local-slot", "skipped (--no-local)".to_string());
10861 } else {
10862 let local_url = with_local
10863 .unwrap_or("http://127.0.0.1:8771")
10864 .trim_end_matches('/');
10865 let already_local = crate::endpoints::self_endpoints(
10866 &config::read_relay_state().unwrap_or_else(|_| json!({})),
10867 )
10868 .iter()
10869 .any(|e| e.relay_url == local_url);
10870 if relay_url.trim_end_matches('/') == local_url || already_local {
10871 step("local-slot", "already covered".to_string());
10872 } else if crate::relay_client::RelayClient::new(local_url)
10873 .check_healthz()
10874 .is_ok()
10875 {
10876 match cmd_bind_relay(
10877 local_url,
10878 Some("local"),
10879 false,
10880 false,
10881 false,
10882 ) {
10883 Ok(()) => step(
10884 "local-slot",
10885 format!("dual-bound local relay {local_url} for sister routing"),
10886 ),
10887 Err(e) => step("local-slot", format!("skipped local relay: {e}")),
10888 }
10889 } else {
10890 step(
10891 "local-slot",
10892 format!(
10893 "no local relay reachable at {local_url} — federation only \
10894 (sisters resolve via session-list)"
10895 ),
10896 );
10897 }
10898 }
10899
10900 match crate::ensure_up::ensure_daemon_running() {
10902 Ok(true) => step("daemon", "started fresh background daemon".to_string()),
10903 Ok(false) => step("daemon", "already running".to_string()),
10904 Err(e) => step(
10905 "daemon",
10906 format!("WARNING: could not start daemon: {e}. Run `wire daemon &` manually."),
10907 ),
10908 }
10909
10910 let summary =
10912 "ready. `wire pair <peer>@<relay>` to pair, `wire send <peer> \"<msg>\"` to send, \
10913 `wire monitor` to watch incoming events."
10914 .to_string();
10915 step("ready", summary.clone());
10916
10917 if as_json {
10918 let steps_json: Vec<_> = report
10919 .iter()
10920 .map(|(k, v)| json!({"stage": k, "detail": v}))
10921 .collect();
10922 println!(
10923 "{}",
10924 serde_json::to_string(&json!({
10925 "nick": canonical,
10926 "relay": relay_url,
10927 "steps": steps_json,
10928 }))?
10929 );
10930 }
10931 Ok(())
10932}
10933
10934fn strip_proto(url: &str) -> String {
10936 url.trim_start_matches("https://")
10937 .trim_start_matches("http://")
10938 .to_string()
10939}
10940
10941fn cmd_pair_megacommand(
10955 handle_arg: &str,
10956 relay_override: Option<&str>,
10957 timeout_secs: u64,
10958 _as_json: bool,
10959) -> Result<()> {
10960 let parsed = crate::pair_profile::parse_handle(handle_arg)?;
10961 let peer_handle = parsed.nick.clone();
10962
10963 eprintln!("wire pair: resolving {handle_arg}...");
10964 cmd_add(
10965 handle_arg,
10966 relay_override,
10967 false,
10968 false,
10969 )?;
10970
10971 eprintln!(
10972 "wire pair: intro delivered. waiting up to {timeout_secs}s for {peer_handle} \
10973 to ack (their daemon must be running + pulling)..."
10974 );
10975
10976 let _ = run_sync_pull();
10980
10981 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
10982 let poll_interval = std::time::Duration::from_millis(500);
10983
10984 loop {
10985 let _ = run_sync_pull();
10987 let relay_state = config::read_relay_state()?;
10988 let peer_entry = relay_state
10989 .get("peers")
10990 .and_then(|p| p.get(&peer_handle))
10991 .cloned();
10992 let token = peer_entry
10993 .as_ref()
10994 .and_then(|e| e.get("slot_token"))
10995 .and_then(Value::as_str)
10996 .unwrap_or("");
10997
10998 if !token.is_empty() {
10999 let trust = config::read_trust()?;
11001 let pinned_in_trust = trust
11002 .get("agents")
11003 .and_then(|a| a.get(&peer_handle))
11004 .is_some();
11005 println!(
11006 "wire pair: paired with {peer_handle}.\n trust: {} bilateral: yes (slot_token recorded)\n next: `wire send {peer_handle} \"<msg>\"`",
11007 if pinned_in_trust {
11008 "VERIFIED"
11009 } else {
11010 "MISSING (bug)"
11011 }
11012 );
11013 return Ok(());
11014 }
11015
11016 if std::time::Instant::now() >= deadline {
11017 bail!(
11024 "wire pair: timed out after {timeout_secs}s. \
11025 peer {peer_handle} never sent pair_drop_ack. \
11026 likely causes: (a) their daemon is down — ask them to run \
11027 `wire status` and `wire daemon &`; (b) their binary is older \
11028 than 0.5.x and doesn't understand pair_drop events — ask \
11029 them to `wire upgrade`; (c) network / relay blip — re-run \
11030 `wire pair {handle_arg}` to retry."
11031 );
11032 }
11033
11034 std::thread::sleep(poll_interval);
11035 }
11036}
11037
11038fn cmd_claim(
11039 nick: &str,
11040 relay_override: Option<&str>,
11041 public_url: Option<&str>,
11042 hidden: bool,
11043 as_json: bool,
11044) -> Result<()> {
11045 let (_did, relay_url, slot_id, slot_token) =
11048 crate::pair_invite::ensure_self_with_relay(relay_override)?;
11049 let card = config::read_agent_card()?;
11050
11051 let did = card.get("did").and_then(Value::as_str).unwrap_or_default();
11060 let canonical = crate::agent_card::display_handle_from_did(did).to_string();
11061 if !canonical.is_empty() && nick != canonical && !as_json {
11062 eprintln!(
11063 "wire claim: typed `{nick}` ignored — one-name rule. Claiming your persona `{canonical}`."
11064 );
11065 }
11066 let nick = if canonical.is_empty() {
11067 nick
11068 } else {
11069 canonical.as_str()
11070 };
11071 if !crate::pair_profile::is_valid_nick(nick) {
11072 bail!(
11073 "phyllis: {nick:?} won't fit in the books — handles need 2-32 chars, lowercase [a-z0-9_-], not on the reserved list"
11074 );
11075 }
11076
11077 let client = crate::relay_client::RelayClient::new(&relay_url);
11078 let discoverable = if hidden { Some(false) } else { None };
11082 let resp =
11083 client.handle_claim_v2(nick, &slot_id, &slot_token, public_url, &card, discoverable)?;
11084
11085 if as_json {
11086 println!(
11087 "{}",
11088 serde_json::to_string(&json!({
11089 "nick": nick,
11090 "relay": relay_url,
11091 "response": resp,
11092 }))?
11093 );
11094 } else {
11095 let domain = public_url
11099 .unwrap_or(&relay_url)
11100 .trim_start_matches("https://")
11101 .trim_start_matches("http://")
11102 .trim_end_matches('/')
11103 .split('/')
11104 .next()
11105 .unwrap_or("<this-relay-domain>")
11106 .to_string();
11107 println!("claimed {nick} on {relay_url} — others can reach you at: {nick}@{domain}");
11108 println!("verify with: wire whois {nick}@{domain}");
11109 }
11110 Ok(())
11111}
11112
11113fn cmd_profile(action: ProfileAction) -> Result<()> {
11114 match action {
11115 ProfileAction::Set { field, value, json } => {
11116 let parsed: Value =
11120 serde_json::from_str(&value).unwrap_or(Value::String(value.clone()));
11121 let new_profile = crate::pair_profile::write_profile_field(&field, parsed)?;
11122 if json {
11123 println!(
11124 "{}",
11125 serde_json::to_string(&json!({
11126 "field": field,
11127 "profile": new_profile,
11128 }))?
11129 );
11130 } else {
11131 println!("profile.{field} set");
11132 }
11133 }
11134 ProfileAction::Get { json } => return cmd_whois(None, json, None),
11135 ProfileAction::Clear { field, json } => {
11136 let new_profile = crate::pair_profile::write_profile_field(&field, Value::Null)?;
11137 if json {
11138 println!(
11139 "{}",
11140 serde_json::to_string(&json!({
11141 "field": field,
11142 "cleared": true,
11143 "profile": new_profile,
11144 }))?
11145 );
11146 } else {
11147 println!("profile.{field} cleared");
11148 }
11149 }
11150 }
11151 Ok(())
11152}
11153
11154fn cmd_setup(apply: bool) -> Result<()> {
11157 use std::path::PathBuf;
11158
11159 let entry = json!({"command": "wire", "args": ["mcp"]});
11160 let entry_pretty = serde_json::to_string_pretty(&json!({"wire": &entry}))?;
11161
11162 let mut targets: Vec<(&str, PathBuf)> = Vec::new();
11165 if let Some(home) = dirs::home_dir() {
11166 targets.push(("Claude Code", home.join(".claude.json")));
11169 targets.push(("Claude Code (alt)", home.join(".config/claude/mcp.json")));
11171 #[cfg(target_os = "macos")]
11173 targets.push((
11174 "Claude Desktop (macOS)",
11175 home.join("Library/Application Support/Claude/claude_desktop_config.json"),
11176 ));
11177 #[cfg(target_os = "windows")]
11179 if let Ok(appdata) = std::env::var("APPDATA") {
11180 targets.push((
11181 "Claude Desktop (Windows)",
11182 PathBuf::from(appdata).join("Claude/claude_desktop_config.json"),
11183 ));
11184 }
11185 targets.push(("Cursor", home.join(".cursor/mcp.json")));
11187 }
11188 targets.push(("project-local (.mcp.json)", PathBuf::from(".mcp.json")));
11190
11191 println!("wire setup\n");
11192 println!("MCP server snippet (add this to your client's mcpServers):");
11193 println!();
11194 println!("{entry_pretty}");
11195 println!();
11196
11197 if !apply {
11198 println!("Probable MCP host config locations on this machine:");
11199 for (name, path) in &targets {
11200 let marker = if path.exists() {
11201 "✓ found"
11202 } else {
11203 " (would create)"
11204 };
11205 println!(" {marker:14} {name}: {}", path.display());
11206 }
11207 println!();
11208 println!("Run `wire setup --apply` to merge wire into each config above.");
11209 println!(
11210 "Existing entries with a different command keep yours unchanged unless wire's exact entry is missing."
11211 );
11212 return Ok(());
11213 }
11214
11215 let mut modified: Vec<String> = Vec::new();
11216 let mut skipped: Vec<String> = Vec::new();
11217 for (name, path) in &targets {
11218 match upsert_mcp_entry(path, "wire", &entry) {
11219 Ok(true) => modified.push(format!("✓ {name} ({})", path.display())),
11220 Ok(false) => skipped.push(format!(" {name} ({}): already configured", path.display())),
11221 Err(e) => skipped.push(format!("✗ {name} ({}): {e}", path.display())),
11222 }
11223 }
11224 if !modified.is_empty() {
11225 println!("Modified:");
11226 for line in &modified {
11227 println!(" {line}");
11228 }
11229 println!();
11230 println!("Restart the app(s) above to load wire MCP.");
11231 }
11232 if !skipped.is_empty() {
11233 println!();
11234 println!("Skipped:");
11235 for line in &skipped {
11236 println!(" {line}");
11237 }
11238 }
11239 Ok(())
11240}
11241
11242fn upsert_mcp_entry(path: &std::path::Path, server_name: &str, entry: &Value) -> Result<bool> {
11245 let mut cfg: Value = if path.exists() {
11246 let body = std::fs::read_to_string(path).context("reading config")?;
11247 serde_json::from_str(&body).unwrap_or_else(|_| json!({}))
11248 } else {
11249 json!({})
11250 };
11251 if !cfg.is_object() {
11252 cfg = json!({});
11253 }
11254 let root = cfg.as_object_mut().unwrap();
11255 let servers = root
11256 .entry("mcpServers".to_string())
11257 .or_insert_with(|| json!({}));
11258 if !servers.is_object() {
11259 *servers = json!({});
11260 }
11261 let map = servers.as_object_mut().unwrap();
11262 if map.get(server_name) == Some(entry) {
11263 return Ok(false);
11264 }
11265 map.insert(server_name.to_string(), entry.clone());
11266 if let Some(parent) = path.parent()
11267 && !parent.as_os_str().is_empty()
11268 {
11269 std::fs::create_dir_all(parent).context("creating parent dir")?;
11270 }
11271 let out = serde_json::to_string_pretty(&cfg)? + "\n";
11272 std::fs::write(path, out).context("writing config")?;
11273 Ok(true)
11274}
11275
11276const STATUSLINE_RENDERER: &str = include_str!("../assets/wire-statusline.sh");
11282
11283fn cmd_setup_statusline(apply: bool, remove: bool) -> Result<()> {
11289 use std::path::PathBuf;
11290 let cfg_dir: PathBuf = std::env::var_os("CLAUDE_CONFIG_DIR")
11291 .map(PathBuf::from)
11292 .or_else(|| dirs::home_dir().map(|h| h.join(".claude")))
11293 .ok_or_else(|| anyhow!("cannot locate Claude config dir (set $CLAUDE_CONFIG_DIR)"))?;
11294 let settings_path = cfg_dir.join("settings.json");
11295 let script_path = cfg_dir.join("wire-statusline.sh");
11296 let (command, command_warn) = statusline_command(&script_path);
11301
11302 println!("wire setup --statusline\n");
11303 println!("Claude config dir: {}", cfg_dir.display());
11304 println!(" renderer: {}", script_path.display());
11305 println!(" settings: {}", settings_path.display());
11306 if let Some(w) = &command_warn {
11307 println!(" ⚠ {w}");
11308 }
11309 println!();
11310
11311 if remove {
11312 if !apply {
11313 println!("Would REMOVE the statusLine key from settings.json and delete the renderer.");
11314 println!("Run `wire setup --statusline --remove --apply` to do it.");
11315 return Ok(());
11316 }
11317 let dropped = remove_statusline_entry(&settings_path)?;
11318 let script_gone = if script_path.exists() {
11319 std::fs::remove_file(&script_path).is_ok()
11320 } else {
11321 false
11322 };
11323 println!(
11324 "Removed: statusLine key {} · renderer {}",
11325 if dropped { "dropped" } else { "absent" },
11326 if script_gone { "deleted" } else { "absent" }
11327 );
11328 return Ok(());
11329 }
11330
11331 if !apply {
11332 println!("Would write the renderer above and merge into settings.json:");
11333 println!();
11334 println!(" \"statusLine\": {{ \"type\": \"command\", \"command\": \"{command}\" }}");
11335 println!();
11336 println!("Resulting statusline: ● <emoji> <nickname> · <cwd>");
11337 println!("Run `wire setup --statusline --apply` to install.");
11338 println!("(Existing settings.json keys are preserved; an invalid settings.json aborts.)");
11339 return Ok(());
11340 }
11341
11342 if let Some(parent) = script_path.parent() {
11343 std::fs::create_dir_all(parent).context("creating Claude config dir")?;
11344 }
11345 std::fs::write(&script_path, STATUSLINE_RENDERER).context("writing renderer script")?;
11346 #[cfg(unix)]
11347 {
11348 use std::os::unix::fs::PermissionsExt;
11349 if let Ok(meta) = std::fs::metadata(&script_path) {
11350 let mut perms = meta.permissions();
11351 perms.set_mode(0o755);
11352 let _ = std::fs::set_permissions(&script_path, perms);
11353 }
11354 }
11355 let changed = upsert_statusline_entry(&settings_path, &command)?;
11356 println!("✓ renderer written: {}", script_path.display());
11357 if changed {
11358 println!("✓ merged statusLine into: {}", settings_path.display());
11359 } else {
11360 println!(
11361 " settings.json already configured: {}",
11362 settings_path.display()
11363 );
11364 }
11365 println!();
11366 println!("Restart Claude Code (or reopen the session) to see your persona in the statusline.");
11367 Ok(())
11368}
11369
11370fn upsert_statusline_entry(path: &std::path::Path, command: &str) -> Result<bool> {
11374 let mut cfg: Value = if path.exists() {
11375 let body = std::fs::read_to_string(path).context("reading settings.json")?;
11376 if body.trim().is_empty() {
11377 json!({})
11378 } else {
11379 serde_json::from_str(&body).context(
11380 "settings.json exists but is not valid JSON — refusing to clobber; fix or remove it first",
11381 )?
11382 }
11383 } else {
11384 json!({})
11385 };
11386 if !cfg.is_object() {
11387 bail!("settings.json root is not a JSON object — refusing to clobber");
11388 }
11389 let desired = json!({"type": "command", "command": command});
11390 let root = cfg.as_object_mut().unwrap();
11391 if root.get("statusLine") == Some(&desired) {
11392 return Ok(false);
11393 }
11394 root.insert("statusLine".to_string(), desired);
11395 if let Some(parent) = path.parent()
11396 && !parent.as_os_str().is_empty()
11397 {
11398 std::fs::create_dir_all(parent).context("creating parent dir")?;
11399 }
11400 let out = serde_json::to_string_pretty(&cfg)? + "\n";
11401 std::fs::write(path, out).context("writing settings.json")?;
11402 Ok(true)
11403}
11404
11405fn remove_statusline_entry(path: &std::path::Path) -> Result<bool> {
11408 if !path.exists() {
11409 return Ok(false);
11410 }
11411 let body = std::fs::read_to_string(path).context("reading settings.json")?;
11412 if body.trim().is_empty() {
11413 return Ok(false);
11414 }
11415 let mut cfg: Value = serde_json::from_str(&body)
11416 .context("settings.json is not valid JSON — refusing to edit")?;
11417 let Some(root) = cfg.as_object_mut() else {
11418 return Ok(false);
11419 };
11420 if root.remove("statusLine").is_none() {
11421 return Ok(false);
11422 }
11423 let out = serde_json::to_string_pretty(&cfg)? + "\n";
11424 std::fs::write(path, out).context("writing settings.json")?;
11425 Ok(true)
11426}
11427
11428fn statusline_command(script_path: &std::path::Path) -> (String, Option<String>) {
11431 #[cfg(windows)]
11432 {
11433 match resolve_git_bash() {
11434 Some(bash) => (format!("\"{}\" \"{}\"", bash, script_path.display()), None),
11435 None => (
11436 format!("bash \"{}\"", script_path.display()),
11437 Some(
11438 "could not locate git-bash; using bare `bash`. On Windows that may resolve to \
11439 WSL (System32\\bash.exe) and the statusline will be blank — install Git for \
11440 Windows or set statusLine.command to your git-bash bash.exe path."
11441 .to_string(),
11442 ),
11443 ),
11444 }
11445 }
11446 #[cfg(unix)]
11447 {
11448 (format!("bash \"{}\"", script_path.display()), None)
11449 }
11450}
11451
11452#[cfg(windows)]
11456fn resolve_git_bash() -> Option<String> {
11457 use std::path::PathBuf;
11458 if let Ok(out) = std::process::Command::new("where.exe").arg("bash").output()
11461 && out.status.success()
11462 {
11463 for line in String::from_utf8_lossy(&out.stdout).lines() {
11464 let p = line.trim();
11465 if !p.is_empty() && !p.to_lowercase().contains("\\system32\\") {
11466 return Some(p.to_string());
11467 }
11468 }
11469 }
11470 let candidates = [
11472 std::env::var("ProgramFiles")
11473 .ok()
11474 .map(|p| format!("{p}\\Git\\bin\\bash.exe")),
11475 std::env::var("ProgramFiles(x86)")
11476 .ok()
11477 .map(|p| format!("{p}\\Git\\bin\\bash.exe")),
11478 std::env::var("LocalAppData")
11479 .ok()
11480 .map(|p| format!("{p}\\Programs\\Git\\bin\\bash.exe")),
11481 ];
11482 candidates
11483 .into_iter()
11484 .flatten()
11485 .find(|c| PathBuf::from(c).exists())
11486}
11487
11488#[cfg(test)]
11489mod statusline_tests {
11490 use super::*;
11491
11492 #[test]
11493 fn statusline_merge_preserves_keys_and_is_idempotent() {
11494 let dir = tempfile::tempdir().unwrap();
11495 let path = dir.path().join("settings.json");
11496 std::fs::write(&path, r#"{"theme":"dark","model":"opus"}"#).unwrap();
11497 assert!(upsert_statusline_entry(&path, "bash /x.sh").unwrap());
11499 let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
11500 assert_eq!(v["theme"], "dark");
11501 assert_eq!(v["model"], "opus");
11502 assert_eq!(v["statusLine"]["type"], "command");
11503 assert_eq!(v["statusLine"]["command"], "bash /x.sh");
11504 assert!(!upsert_statusline_entry(&path, "bash /x.sh").unwrap());
11506 assert!(remove_statusline_entry(&path).unwrap());
11508 let v2: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
11509 assert_eq!(v2["theme"], "dark");
11510 assert!(v2.get("statusLine").is_none());
11511 assert!(!remove_statusline_entry(&path).unwrap());
11513 }
11514
11515 #[test]
11516 fn statusline_merge_refuses_to_clobber_invalid_json() {
11517 let dir = tempfile::tempdir().unwrap();
11518 let path = dir.path().join("settings.json");
11519 std::fs::write(&path, "this is not json {").unwrap();
11520 let err = upsert_statusline_entry(&path, "bash /x.sh").unwrap_err();
11521 assert!(
11522 format!("{err:#}").contains("not valid JSON"),
11523 "err: {err:#}"
11524 );
11525 assert_eq!(
11527 std::fs::read_to_string(&path).unwrap(),
11528 "this is not json {"
11529 );
11530 }
11531
11532 #[test]
11533 fn statusline_creates_settings_when_absent() {
11534 let dir = tempfile::tempdir().unwrap();
11535 let path = dir.path().join("settings.json");
11536 assert!(upsert_statusline_entry(&path, "bash /x.sh").unwrap());
11537 let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
11538 assert_eq!(v["statusLine"]["command"], "bash /x.sh");
11539 }
11540}
11541
11542fn cmd_notify(
11545 interval_secs: u64,
11546 peer_filter: Option<&str>,
11547 once: bool,
11548 as_json: bool,
11549) -> Result<()> {
11550 use crate::inbox_watch::InboxWatcher;
11551 let cursor_path = config::state_dir()?.join("notify.cursor");
11552 let mut watcher = InboxWatcher::from_cursor_file(&cursor_path)?;
11553
11554 let sweep = |watcher: &mut InboxWatcher| -> Result<()> {
11555 let events = watcher.poll()?;
11556 for ev in events {
11557 if let Some(p) = peer_filter
11558 && ev.peer != p
11559 {
11560 continue;
11561 }
11562 if as_json {
11563 println!("{}", serde_json::to_string(&ev)?);
11564 } else {
11565 os_notify_inbox_event(&ev);
11566 }
11567 }
11568 watcher.save_cursors(&cursor_path)?;
11569 Ok(())
11570 };
11571
11572 if once {
11573 return sweep(&mut watcher);
11574 }
11575
11576 let interval = std::time::Duration::from_secs(interval_secs.max(1));
11577 loop {
11578 if let Err(e) = sweep(&mut watcher) {
11579 eprintln!("wire notify: sweep error: {e}");
11580 }
11581 std::thread::sleep(interval);
11582 }
11583}
11584
11585fn os_notify_inbox_event(ev: &crate::inbox_watch::InboxEvent) {
11586 let who = persona_label(&ev.peer);
11587 let title = if ev.verified {
11588 format!("wire ← {who}")
11589 } else {
11590 format!("wire ← {who} (UNVERIFIED)")
11591 };
11592 let body = format!("{}: {}", ev.kind, ev.body_preview);
11593 crate::os_notify::toast(&title, &body);
11594}
11595
11596#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
11597fn os_toast(title: &str, body: &str) {
11598 eprintln!("[wire notify] {title}\n {body}");
11599}
11600
11601