wire/cli/mod.rs
1//! `wire` CLI surface.
2//!
3//! Every subcommand emits human-readable text by default and structured JSON
4//! when `--json` is passed. Stable JSON shape is part of the API contract —
5//! see `docs/AGENT_INTEGRATION.md`.
6//!
7//! Subcommand split:
8//! - **agent-safe**: `whoami`, `peers`, `verify`, `send`, `tail` — pure
9//! message-layer ops, no trust establishment.
10//! - **trust-establishing**: `init`, `dial`, `accept`/`reject`,
11//! `invite`/`accept-invite`. The bilateral gate (operator-side `accept`)
12//! preserves the human-in-loop step — see `docs/THREAT_MODEL.md` T10/T14.
13
14use anyhow::{Context, Result, anyhow, bail};
15use clap::{Parser, Subcommand};
16use serde_json::{Value, json};
17
18use crate::config;
19
20mod comms;
21mod demo;
22mod group;
23mod identity;
24mod lifecycle;
25mod mesh;
26mod pairing;
27mod relay;
28mod session;
29mod setup;
30mod status;
31mod upgrade;
32
33pub(crate) use comms::here_summary;
34pub(crate) use comms::parse_deadline_until;
35pub(crate) use relay::cmd_bind_relay;
36pub use relay::error_smells_like_slot_4xx;
37pub use relay::run_sync_pull;
38pub use relay::run_sync_push;
39pub use session::maybe_auto_init_cwd_session;
40
41// Re-exports for cross-module callers (comms.rs, mcp.rs, etc.).
42pub(crate) use pairing::{DialTarget, resolve_name_to_target};
43pub(crate) use pairing::{
44 ResolveError, add_local_sister_core, cmd_add_local_sister, resolve_peer_handle,
45};
46// Re-exports for identity family: setup.rs calls super::cmd_init / super::cmd_claim;
47// comms.rs + pairing.rs call super::op_claims_from_card; mcp.rs calls crate::cli::op_claims_from_card.
48pub(crate) use identity::op_claims_from_card;
49pub(super) use identity::{cmd_claim, cmd_init};
50
51/// Top-level CLI.
52#[derive(Parser, Debug)]
53#[command(
54 name = "wire",
55 version,
56 about = "Magic-wormhole for AI agents — bilateral signed-message bus",
57 long_about = None,
58 after_help = "\x1b[1mStart here:\x1b[0m\n \
59 wire up come online (one command)\n \
60 wire dial <name> \"hi\" reach a peer and send\n \
61 wire tail read replies\n \
62 wire here who am I, who's around?\n \
63 wire doctor something off? full health check\n\
64 \nThe ~40 verbs below are mostly plumbing — the five above cover daily use.\n\
65 Guide: https://github.com/SlanchaAi/wire"
66)]
67pub struct Cli {
68 #[command(subcommand)]
69 pub command: Command,
70}
71
72#[derive(Subcommand, Debug)]
73pub enum Command {
74 /// Generate a keypair, write self-card, and bind an inbound slot.
75 /// (HUMAN-ONLY — DO NOT exec from agents.)
76 ///
77 /// v0.9: refuses to create a slotless session by default. Pre-v0.9
78 /// the silent slotless state caused the 2026-05-23 silent-fail
79 /// incident — pairing + sending succeeded but peers black-holed
80 /// inbound. Operators must now name how the session is reachable:
81 /// `--relay <url>` (binds a slot inline) or `--offline` (opt into
82 /// slotless, acknowledge `wire bind-relay` is required before any
83 /// pair or send).
84 ///
85 /// Internal primitive — folded into `wire up` and hidden. Your handle is
86 /// your DID-derived persona (one-name rule); there is no name to type.
87 /// Init is the sole naming event: it mints the keypair and the persona is
88 /// derived from it. Users never type this — `wire up` runs it, and
89 /// `wire up --offline` covers offline keygen. Kept as a callable command
90 /// only because `wire up` / `wire session new` invoke it internally.
91 #[command(hide = true)]
92 Init {
93 /// Relay URL — binds an inbound slot in the same step. Required
94 /// unless `--offline` is passed. Example:
95 /// `--relay http://127.0.0.1:8771` (local), `--relay https://wireup.net`
96 /// (federation).
97 #[arg(long)]
98 relay: Option<String>,
99 /// v0.9: opt into a slotless session — keypair only, no inbound
100 /// mailbox. You MUST run `wire bind-relay <url>` before any
101 /// pair / send / dial; until then peers cannot reach you.
102 /// Useful for offline keypair generation; rare in practice.
103 #[arg(long, conflicts_with = "relay")]
104 offline: bool,
105 /// Emit JSON.
106 #[arg(long)]
107 json: bool,
108 },
109 /// Print this agent's identity (DID, fingerprint, mailbox slot).
110 Whoami {
111 #[arg(long)]
112 json: bool,
113 /// Print just `<emoji> <nickname>` (e.g. `🦊 foxtrot-meadow`).
114 /// Plain text, no ANSI escapes. Useful for piping into other tools.
115 #[arg(long, conflicts_with = "json")]
116 short: bool,
117 /// Print `<emoji> <nickname>` wrapped in ANSI 256-color escapes.
118 /// Drop into a Claude Code statusline command for live identity display.
119 #[arg(long, conflicts_with_all = ["json", "short"])]
120 colored: bool,
121 },
122 /// List pinned peers with their tiers and capabilities.
123 Peers {
124 #[arg(long)]
125 json: bool,
126 },
127 /// Emit a shell completion script to stdout.
128 ///
129 /// Pipe to your shell's completion dir to enable tab-completion of
130 /// wire verbs + handles + flags.
131 ///
132 /// Example installs:
133 /// bash: `wire completions bash > /etc/bash_completion.d/wire`
134 /// zsh: `wire completions zsh > ~/.zsh/completions/_wire`
135 /// fish: `wire completions fish > ~/.config/fish/completions/wire.fish`
136 /// pwsh: `wire completions powershell > $PROFILE` (append)
137 /// elvish: `wire completions elvish > ~/.elvish/lib/wire.elv`
138 Completions {
139 /// Shell to generate completions for.
140 #[arg(value_enum)]
141 shell: clap_complete::Shell,
142 },
143 /// One-screen "you are here" — your character, handle, cwd, and neighbors.
144 ///
145 /// Prints the current session's character + handle + cwd, plus a short
146 /// list of neighbors (sister sessions on the local relay, pinned peers).
147 /// Designed for the operator's quick "wait which Claude is this,
148 /// and who's around?" question — no `--json` shuffling, no
149 /// remembering `wire whoami` vs `wire peers` vs `wire session
150 /// list-local`.
151 Here {
152 #[arg(long)]
153 json: bool,
154 },
155 /// List pending-inbound pair requests waiting for your consent.
156 ///
157 /// Operators reach for "what's pending?" not a longer table-dump verb.
158 Pending {
159 #[arg(long)]
160 json: bool,
161 },
162 /// Sign and queue an event to a peer.
163 ///
164 /// Forms (P0.S 0.5.11):
165 /// wire send <peer> <body> # kind defaults to "claim"
166 /// wire send <peer> <kind> <body> # explicit kind (back-compat)
167 /// wire send <peer> - # body from stdin (kind=claim)
168 /// wire send <peer> @/path/to/body.json # body from file
169 Send {
170 /// Peer handle (without `did:wire:` prefix).
171 peer: String,
172 /// When `<body>` is omitted, this is the event body (kind defaults
173 /// to `claim`). When both this and `<body>` are given, this is the
174 /// event kind (`decision`, `claim`, etc., or numeric kind id) and
175 /// the next positional is the body.
176 kind_or_body: String,
177 /// Event body — free-form text, `@/path/to/body.json` to load from
178 /// a file, or `-` to read from stdin. Optional; omit to use
179 /// `<kind_or_body>` as the body with kind=`claim`.
180 body: Option<String>,
181 /// Advisory deadline: duration (`30m`, `2h`, `1d`) or RFC3339 timestamp.
182 #[arg(long)]
183 deadline: Option<String>,
184 /// v0.10: skip the v0.9 auto-pair-on-miss behavior. Send fails
185 /// loudly if the peer isn't pinned yet. Use when you want strict
186 /// "no implicit dialing" semantics — scripts that error vs.
187 /// performing a side-effecting pair as a fallback.
188 #[arg(long)]
189 no_auto_pair: bool,
190 /// v0.14.2: opt back into the legacy outbox→daemon-push pipeline.
191 /// By default `wire send` POSTs to the peer's relay slot
192 /// synchronously and returns a real `delivered` / `duplicate` /
193 /// `failed` verdict. With `--queue` the event is appended to
194 /// `<outbox_dir>/<peer>.jsonl` and the daemon's push loop
195 /// drains it later (pre-v0.14.2 behavior). Use for offline
196 /// buffering, batch sends, or pre-pair queueing.
197 #[arg(long)]
198 queue: bool,
199 /// Emit JSON.
200 #[arg(long)]
201 json: bool,
202 },
203 /// Fan a single signed message out to every org-mate tagged with a project
204 /// (RFC-001 §6 client-side project routing).
205 ///
206 /// Recipients = every pinned peer at effective tier **>= ORG_VERIFIED**
207 /// whose card carries `project == <project>`. The tier floor is the trust
208 /// gate; `project` is unsigned routing metadata (it picks who, never grants
209 /// trust). Delivery is N synchronous one-to-one pushes — wire has no
210 /// broadcast primitive. Zero matching peers is a no-op success.
211 ///
212 /// Set your own project tag with `wire project <tag>`; peers see it on your
213 /// card once they pin (or re-pull) it.
214 SendProject {
215 /// Project tag to fan out to (must match peers' card `project`).
216 project: String,
217 /// Event body — free-form text, `@/path/to/body.json`, or `-` for stdin.
218 body: String,
219 /// Event kind (`claim`, `decision`, … or numeric id). Default `claim`.
220 #[arg(long, default_value = "claim")]
221 kind: String,
222 /// Advisory deadline: duration (`30m`, `2h`, `1d`) or RFC3339 timestamp.
223 #[arg(long)]
224 deadline: Option<String>,
225 /// Emit JSON.
226 #[arg(long)]
227 json: bool,
228 },
229 /// Show, set, or clear this session's project routing tag (RFC-001 §6).
230 ///
231 /// `wire project` prints the current tag; `wire project <tag>` sets it;
232 /// `wire project --clear` removes it. The tag is unsigned metadata on your
233 /// agent-card — peers who pin your card use it to target
234 /// `wire send-project <tag>` fan-outs. Set it before pairing (or re-pair
235 /// after) so the change reaches peers.
236 Project {
237 /// New project tag. Omit to print the current tag.
238 tag: Option<String>,
239 /// Clear the project tag instead of setting one.
240 #[arg(long, conflicts_with = "tag")]
241 clear: bool,
242 /// Emit JSON.
243 #[arg(long)]
244 json: bool,
245 },
246 /// "Go talk to this name." The one verb operators reach for.
247 ///
248 /// `wire dial <name>` accepts a character nickname (`noble-slate`),
249 /// a session name (`slancha-api`), a card handle, or a DID — whichever
250 /// face you happen to know the peer by. Resolution order:
251 ///
252 /// 1. Already-pinned peer? → no-op (or send if a message was passed).
253 /// 2. Local sister session? → bilateral pair via the disk-read
254 /// `--local-sister` path (no relay round-trip, no .well-known
255 /// lookup, no SAS digits).
256 /// 3. Otherwise → bail with a clear hint pointing at federation
257 /// syntax (`wire dial <handle>@<relay>` for cross-machine peers).
258 ///
259 /// With an optional message, `wire dial <name> "<msg>"` also sends
260 /// the message synchronously after the pair lands (#187 collapsed
261 /// the legacy queue→push step into a single direct relay POST;
262 /// the response carries the actual delivered/duplicate/etc.
263 /// verdict). Idempotent: re-dialling a known peer just sends.
264 Dial {
265 /// Peer name. Character nickname (preferred), session name,
266 /// card handle, or DID — anything that identifies the peer to
267 /// you.
268 name: String,
269 /// Optional first message to send after the pair lands. Same
270 /// semantics as the body argument to `wire send`. Defaults to
271 /// kind=claim.
272 message: Option<String>,
273 /// Emit JSON.
274 #[arg(long)]
275 json: bool,
276 },
277 /// Stream signed events from peers.
278 ///
279 /// Defaults to NEWEST-N orientation: with `--limit N`, prints the most
280 /// recent N events across all matched peers, sorted chronologically
281 /// (oldest of the window first, newest last — same orientation as Unix
282 /// `tail`). Pass `--oldest` to flip back to first-N (FIFO) behaviour.
283 /// `--limit 0` returns the full inbox in chronological order.
284 Tail {
285 /// Optional peer filter; if omitted, tails all peers.
286 peer: Option<String>,
287 /// Emit JSONL (one event per line).
288 #[arg(long)]
289 json: bool,
290 /// Maximum events to print. 0 = print everything (oldest → newest).
291 #[arg(long, default_value_t = 0)]
292 limit: usize,
293 /// Return the FIRST `--limit` events (oldest-N) instead of the
294 /// default last-N (newest-N). No effect when `--limit` is 0.
295 #[arg(long)]
296 oldest: bool,
297 },
298 /// Live tail of new inbox events across all pinned peers — one line per
299 /// new event, handshake (pair_drop / pair_drop_ack / heartbeat) filtered
300 /// by default.
301 ///
302 /// Designed to be left running in an agent harness's stream-watcher
303 /// (Claude Code Monitor tool, etc.) so peer messages surface in the
304 /// session as they arrive, not on next manual `wire pull`.
305 ///
306 /// See docs/AGENT_INTEGRATION.md for the recommended Monitor invocation
307 /// template.
308 Monitor {
309 /// Only show events from this peer.
310 #[arg(long)]
311 peer: Option<String>,
312 /// Emit JSONL (one InboxEvent per line) for tooling consumption.
313 #[arg(long)]
314 json: bool,
315 /// Include handshake events (pair_drop, pair_drop_ack, heartbeat).
316 /// Default filters them out as noise.
317 #[arg(long)]
318 include_handshake: bool,
319 /// Poll interval in milliseconds. Lower = lower latency, higher CPU.
320 #[arg(long, default_value_t = 500)]
321 interval_ms: u64,
322 /// Replay last N events from history before going live (0 = none).
323 #[arg(long, default_value_t = 0)]
324 replay: usize,
325 },
326 /// Verify a signed event from a JSON file or stdin (`-`).
327 Verify {
328 /// Path to event JSON, or `-` for stdin.
329 path: String,
330 /// Emit JSON.
331 #[arg(long)]
332 json: bool,
333 },
334 /// Run the MCP (Model Context Protocol) server over stdio.
335 /// This is how Claude Desktop / Claude Code / Cursor / etc. expose
336 /// `wire_send`, `wire_tail`, etc. as native tools.
337 Mcp,
338 /// Run a relay server on this host.
339 RelayServer {
340 /// Bind address (e.g. `127.0.0.1:8770`).
341 #[arg(long, default_value = "127.0.0.1:8770")]
342 bind: String,
343 /// v0.5.17: refuse non-loopback binds, skip phonebook listing,
344 /// skip `.well-known/wire/agent` serving. The relay becomes
345 /// invisible from outside the box — only same-machine processes
346 /// can pair through it. Right call for within-machine agent
347 /// coordination where you don't want metadata leaking to a
348 /// public relay. Pair this with `wire session new` which probes
349 /// `127.0.0.1:8771` and allocates a local slot automatically.
350 #[arg(long)]
351 local_only: bool,
352 /// v0.7.0-alpha.16: bind to a Unix Domain Socket instead of TCP.
353 /// When set, --bind is ignored. Implies --local-only semantics
354 /// (no phonebook, no .well-known). Socket is chmod 0600 (owner-
355 /// rw only), giving SO_PEERCRED-equivalent same-uid trust for
356 /// sister sessions. Unix only (Windows refuses).
357 #[arg(long)]
358 uds: Option<std::path::PathBuf>,
359 },
360 /// Allocate a slot on a relay; bind it to this agent's identity.
361 ///
362 /// v0.5.19 (issue #7): if any peers are pinned to this agent's
363 /// current slot, this command refuses by default — silent migration
364 /// silently black-holes their inbound messages. Pass
365 /// `--migrate-pinned` to acknowledge the risk and proceed, or use
366 /// `wire rotate-slot` (which emits a `wire_close` event to peers)
367 /// for safe rotation.
368 BindRelay {
369 /// Relay base URL, e.g. `http://127.0.0.1:8770`.
370 url: String,
371 /// Endpoint scope: `federation` | `local` | `lan` | `uds`.
372 /// Default inferred from the URL (loopback host -> local,
373 /// `unix://` -> uds, otherwise federation). Pass explicitly when
374 /// the inference is ambiguous (e.g. a federation relay on a
375 /// loopback address in tests).
376 #[arg(long)]
377 scope: Option<String>,
378 /// DESTRUCTIVE: drop all existing self slots and bind only this
379 /// relay (the pre-v0.12 single-slot behavior). Default is
380 /// ADDITIVE — the new slot is appended to `self.endpoints[]`,
381 /// keeping any existing slots so pinned peers are not
382 /// black-holed.
383 #[arg(long)]
384 replace: bool,
385 /// Acknowledge that pinned peers will black-hole until they
386 /// re-pin manually. Required for `--replace` (and same-relay
387 /// rotation) when `state.peers` is non-empty; ignored on fresh
388 /// boxes. Use `wire rotate-slot` instead for the supported
389 /// same-relay rotation path.
390 #[arg(long)]
391 migrate_pinned: bool,
392 #[arg(long)]
393 json: bool,
394 },
395 /// Manually pin a peer's relay slot from out-of-band coordinates.
396 /// Plumbing — prefer `wire dial` (which resolves + pairs for you).
397 AddPeerSlot {
398 /// Peer handle (becomes did:wire:<handle>).
399 handle: String,
400 /// Peer's relay base URL.
401 url: String,
402 /// Peer's slot id.
403 slot_id: String,
404 /// Slot bearer token (shared between paired peers in v0.1).
405 slot_token: String,
406 #[arg(long)]
407 json: bool,
408 },
409 /// Drain outbox JSONL files to peers' relay slots.
410 Push {
411 /// Optional peer filter; default = all peers with outbox entries.
412 peer: Option<String>,
413 #[arg(long)]
414 json: bool,
415 },
416 /// Pull events from our relay slot, verify, write to inbox.
417 Pull {
418 #[arg(long)]
419 json: bool,
420 },
421 /// Print a summary of identity, relay binding, peers, inbox/outbox queue depth.
422 /// Useful as a single "where am I" check.
423 Status {
424 /// Inspect a paired peer's transport / attention / responder health.
425 #[arg(long)]
426 peer: Option<String>,
427 #[arg(long)]
428 json: bool,
429 },
430 /// Publish or inspect auto-responder health for this slot.
431 Responder {
432 #[command(subcommand)]
433 command: ResponderCommand,
434 },
435 /// Pin a peer's signed agent-card from a file. (Manual out-of-band pairing
436 /// — fallback path; the canonical flow is `wire dial <handle>@<relay>`.)
437 Pin {
438 /// Path to peer's signed agent-card JSON.
439 card_file: String,
440 #[arg(long)]
441 json: bool,
442 },
443 /// Allocate a NEW slot on the same relay and abandon the old one.
444 /// Sends a kind=1201 wire_close event to every paired peer over the OLD
445 /// slot announcing the new mailbox before swapping. After rotation,
446 /// peers must re-pair (or operator runs `add-peer-slot` with the new
447 /// coords) — auto-update via wire_close is a v0.2 daemon feature.
448 ///
449 /// Use case: a paired peer turned hostile (T11 in THREAT_MODEL.md —
450 /// abusive bearer-holder spamming your slot). Rotate → old slot is
451 /// orphaned → attacker's leverage gone. Operator pairs again with
452 /// peers they still want.
453 RotateSlot {
454 /// Skip the wire_close announcement to peers (faster but they won't know
455 /// where you went).
456 #[arg(long)]
457 no_announce: bool,
458 #[arg(long)]
459 json: bool,
460 },
461 /// Remove a peer from trust + relay state. Inbox/outbox files for that
462 /// peer are NOT deleted (operator can grep history); pass --purge to
463 /// also wipe the JSONL files.
464 ForgetPeer {
465 /// Peer handle to forget.
466 handle: String,
467 /// Also delete inbox/<handle>.jsonl and outbox/<handle>.jsonl.
468 #[arg(long)]
469 purge: bool,
470 #[arg(long)]
471 json: bool,
472 },
473 /// Multi-session topology: supervisor + every session's daemon liveness.
474 ///
475 /// Supervisor liveness + per-session daemon liveness + unmanaged
476 /// `wire daemon` pids. `wire status` answers "is THIS session syncing?";
477 /// `wire supervisor` answers "what is the supervisor (and every
478 /// session's daemon) doing across the box?".
479 Supervisor {
480 /// Emit JSON instead of human-readable text. The shape matches
481 /// the `SupervisorState` struct in `daemon_supervisor.rs`.
482 #[arg(long)]
483 json: bool,
484 },
485 /// Run a long-lived sync loop: every <interval> seconds, push outbox to
486 /// peers' relay slots and pull inbox from our own slot. Foreground process;
487 /// background it with systemd / `&` / tmux as you prefer.
488 Daemon {
489 /// Sync interval in seconds. Default 5.
490 #[arg(long, default_value_t = 5)]
491 interval: u64,
492 /// Run a single sync cycle and exit (useful for cron-driven setups).
493 #[arg(long)]
494 once: bool,
495 /// v0.14.2 (#162): supervisor mode — read the session registry +
496 /// fork-exec one child `wire daemon` per initialized session,
497 /// each with its own WIRE_HOME pinned. Closes the launchd-blind
498 /// session-isolation gap honey-pine reported: with no cwd
499 /// context, a single launchd-spawned daemon resolves the
500 /// default WIRE_HOME and silently skips every other session.
501 /// Operator-facing: install this mode via `wire service install`
502 /// — the plist now uses `--all-sessions` so every session syncs
503 /// at login without the operator running N tmux panes.
504 #[arg(long)]
505 all_sessions: bool,
506 /// v0.14.2 (#162): run the daemon loop pinned to a specific
507 /// named session by setting WIRE_HOME for the process. The
508 /// supervisor (`--all-sessions`) spawns children with this
509 /// flag; operators can also use it directly for a one-session
510 /// foreground daemon outside the supervisor.
511 #[arg(long)]
512 session: Option<String>,
513 #[arg(long)]
514 json: bool,
515 },
516 /// Manage isolated wire sessions on this machine (v0.5.16).
517 ///
518 /// Each session = its own DID + handle + relay slot + daemon + inbox/
519 /// outbox tree. Use when multiple agents (e.g. Claude Code sessions
520 /// in different projects) run on the same machine — without sessions
521 /// they all share one identity and race the inbox cursor.
522 ///
523 /// Names are derived from `basename(cwd)` and cached in a registry,
524 /// so re-entering the same project reuses the same identity.
525 #[command(subcommand)]
526 Session(SessionCommand),
527 /// Manage this session's identity display layer (character override).
528 /// v0.7.0-alpha.3: agents can rename themselves — operator or Claude
529 /// itself picks a custom nickname + emoji that overrides the
530 /// auto-derived hash-based defaults.
531 Identity {
532 #[command(subcommand)]
533 cmd: IdentityCommand,
534 },
535 /// Orchestration verbs for the
536 /// sister-session mesh. `wire mesh status` is the live view of every
537 /// paired sister (alias for `wire session mesh-status`); `wire mesh
538 /// broadcast` fans one signed event to every pinned peer.
539 #[command(subcommand)]
540 Mesh(MeshCommand),
541 /// Group chat (v0.13.3): create a named group, add VERIFIED peers, and
542 /// send/tail messages across the whole member set. Membership is a signed
543 /// roster (group-scoped tiers, separate from bilateral peer trust).
544 #[command(subcommand)]
545 Group(GroupCommand),
546 /// Mint operator / organization identities for the offline org-membership
547 /// layer (RFC-001): `wire enroll op` / `org-create` / `org-add-member`.
548 #[command(subcommand)]
549 Enroll(EnrollCommand),
550 /// Trust an organization by its domain (RFC-001 §2 DNS-TXT floor):
551 /// `wire org bind <domain>` / `wire org list` / `wire org forget <org_did>`.
552 #[command(subcommand)]
553 Org(OrgCommand),
554 /// Detect known MCP host config locations (Claude Desktop, Claude Code,
555 /// Cursor, project-local) and either print or auto-merge the wire MCP
556 /// server entry. Default prints; pass `--apply` to actually modify config
557 /// files. Idempotent — re-running is safe.
558 Setup {
559 /// Actually write the changes (default = print only).
560 #[arg(long)]
561 apply: bool,
562 /// Install a Claude Code statusLine showing your wire persona
563 /// (liveness dot + emoji + nickname in the persona's accent color +
564 /// cwd) instead of merging the MCP server. Writes a renderer script
565 /// and merges a `statusLine` block into Claude Code's settings.json
566 /// (honors $CLAUDE_CONFIG_DIR). Combine with --apply to write.
567 #[arg(long)]
568 statusline: bool,
569 /// With --statusline: uninstall it (drop the statusLine key + remove
570 /// the renderer script) instead of installing.
571 #[arg(long)]
572 remove: bool,
573 },
574 /// Show an agent's profile. With no arg, prints local self. With a
575 /// `nick@domain` arg, resolves via that domain's `.well-known/wire/agent`
576 /// endpoint and verifies the returned signed card before display.
577 Whois {
578 /// Optional handle (`nick@domain`). Omit to show self.
579 handle: Option<String>,
580 #[arg(long)]
581 json: bool,
582 /// Override the relay base URL used for resolution (default:
583 /// `https://<domain>` from the handle).
584 #[arg(long)]
585 relay: Option<String>,
586 },
587 /// Federation backend of `wire dial` — prefer `wire dial`.
588 ///
589 /// Zero-paste pair with a known handle: resolves `nick@domain` via that
590 /// domain's `.well-known/wire/agent`, then delivers a signed pair-intro
591 /// to the peer's slot via `/v1/handle/intro`. Peer's daemon completes
592 /// the bilateral pin on its next pull (sends back pair_drop_ack carrying
593 /// their slot_token so we can `wire send` to them).
594 Add {
595 /// Peer handle (`nick@domain`), OR a bare sister-session name
596 /// when `--local-sister` is set.
597 handle: String,
598 /// Override the relay base URL used for resolution.
599 #[arg(long)]
600 relay: Option<String>,
601 /// v0.6.6: pair with a sister session on this machine without
602 /// touching federation. Looks up `handle` as a session name in
603 /// `wire session list`, reads that session's agent-card +
604 /// endpoints from disk, pins directly, then delivers the
605 /// `pair_drop` to the sister's local-relay slot. No `.well-known`
606 /// resolution; reserved nicks (`wire`, `slancha`, etc.) are
607 /// addressable because they don't need a federation claim.
608 #[arg(long)]
609 local_sister: bool,
610 #[arg(long)]
611 json: bool,
612 },
613 /// Come online in one command — `wire up` does what used to take five
614 /// (init + bind-relay + claim your persona + background daemon +
615 /// restart-on-login). Idempotent: re-run on an already-set-up box prints
616 /// state without churn.
617 ///
618 /// There is no name to choose: your handle IS your DID-derived persona
619 /// (one-name rule). The optional argument is just which relay to use.
620 ///
621 /// Examples:
622 /// wire up # default public relay (wireup.net)
623 /// wire up @wireup.net # explicit federation relay
624 /// wire up http://127.0.0.1:8771 # a local / self-hosted relay
625 Up {
626 /// Relay to bind + claim your persona on: `@wireup.net`, `wireup.net`,
627 /// or a full URL. Omit for the default public relay. No nick — your
628 /// handle is your DID-derived persona.
629 relay: Option<String>,
630 /// Mint your identity offline — keypair + DID-derived persona, no
631 /// relay bound and nothing claimed. Bind later with `wire up <relay>`
632 /// or `wire bind-relay <relay>`. For air-gapped keygen / bind-later.
633 #[arg(long, conflicts_with_all = ["relay", "with_local"])]
634 offline: bool,
635 /// Also additively dual-bind a LOCAL relay slot for fast same-box
636 /// sister-session routing. Defaults to probing
637 /// `http://127.0.0.1:8771`; pass a URL to override. Local relays
638 /// carry no handle directory, so nothing is claimed there.
639 #[arg(long)]
640 with_local: Option<String>,
641 /// Skip the opportunistic local dual-bind entirely.
642 #[arg(long)]
643 no_local: bool,
644 #[arg(long)]
645 json: bool,
646 },
647 /// See wire work in one command — an ephemeral two-agent round-trip.
648 ///
649 /// Boots a throwaway local relay, mints two temporary identities, pairs
650 /// them, and sends a signed message end-to-end — then tears it all down.
651 /// No install of a relay, no second terminal, no copy-pasting a persona.
652 /// The fastest way to watch two agents talk before setting wire up for
653 /// real. Nothing it creates outlives the command.
654 Demo {
655 /// Emit a JSON result summary instead of the narrated walkthrough.
656 #[arg(long)]
657 json: bool,
658 },
659 /// Diagnose wire setup health. Single command that surfaces every
660 /// silent-fail class — daemon down or duplicated, relay unreachable,
661 /// cursor stuck, pair rejections piling up, trust ↔ directory drift.
662 /// Replaces today's 30-minute manual debug.
663 ///
664 /// Exit code non-zero if any FAIL findings.
665 Doctor {
666 /// Emit JSON.
667 #[arg(long)]
668 json: bool,
669 /// Show last N entries from pair-rejected.jsonl in the report.
670 #[arg(long, default_value_t = 5)]
671 recent_rejections: usize,
672 },
673 /// Update + restart in one step (alias: `wire update`). ALWAYS checks
674 /// crates.io for a newer published wire; if one exists it installs it
675 /// (via `cargo install slancha-wire` when a Rust toolchain is on PATH,
676 /// else by downloading + SHA-256-verifying the prebuilt release binary
677 /// and replacing this one in place), then does the atomic daemon swap —
678 /// kill every `wire daemon`, respawn from the (now-current) binary, write
679 /// a fresh pidfile. No newer version → it skips the install and just
680 /// restarts the daemon. `--check` reports what would happen (available
681 /// update + processes that would be restarted) without doing it;
682 /// `--local` skips the crates.io check and only restarts the daemon
683 /// (offline, or running a local dev build).
684 #[command(visible_alias = "update")]
685 Upgrade {
686 /// Report current vs latest + drift without taking action.
687 #[arg(long)]
688 check: bool,
689 /// Skip the crates.io update check; just restart the daemon from the
690 /// current binary (offline / local dev build).
691 #[arg(long)]
692 local: bool,
693 /// Also kill `wire mcp` server subprocesses after the daemon swap so
694 /// their MCP host (Claude Code / Claude.app / Copilot CLI) respawns
695 /// them on the new binary. Without this, sister sessions keep
696 /// running pre-upgrade MCP code until each one explicitly `/mcp`
697 /// reconnects. Cross-session impact: kills every `wire mcp` found.
698 #[arg(long = "restart-mcp")]
699 restart_mcp: bool,
700 /// v0.14.3 (closes the #198 follow-up): kill the daemons reported in
701 /// `wire supervisor`'s `stale_binary_sessions` set — sister-session
702 /// children alive on an old binary that the supervisor's
703 /// existing-pidfile check intentionally protected from respawn. Once
704 /// each is killed, the `--all-sessions` supervisor respawns it on
705 /// the new binary on its next 10s registry poll. Cross-session
706 /// impact: only sessions flagged stale are touched; in-sync siblings
707 /// are spared. No-op (silent) when no supervisor is running OR no
708 /// stale daemons exist.
709 #[arg(long = "refresh-stale-children")]
710 refresh_stale_children: bool,
711 #[arg(long)]
712 json: bool,
713 },
714 /// Hard-reset this machine to a clean wire state: kill daemons,
715 /// remove service units, de-register the wire MCP entry from host
716 /// configs, and wipe all wire dirs. `--purge` also removes the
717 /// binary + shell lines. Requires --force or a typed confirmation.
718 Nuke {
719 /// Skip the typed confirmation (for automation / test harness).
720 /// `--yes` is an accepted alias.
721 #[arg(long, visible_alias = "yes")]
722 force: bool,
723 /// Also remove the `wire` binary + shell PATH/env lines.
724 #[arg(long)]
725 purge: bool,
726 /// Print what would be removed and exit without changing anything.
727 #[arg(long)]
728 dry_run: bool,
729 /// Confirm nuking a machine with a LIVE operator install
730 /// (registry-bound sessions). The unit/process/MCP teardown is
731 /// machine-global even under a temp WIRE_HOME, so a bound
732 /// default registry refuses without this flag.
733 #[arg(long)]
734 really_this_machine: bool,
735 #[arg(long)]
736 json: bool,
737 },
738 /// Install / inspect / remove a launchd plist (macOS) or systemd
739 /// user unit (linux) that runs `wire daemon` on login + restarts
740 /// on crash. Replaces today's "background it with tmux/&/systemd
741 /// as you prefer" footgun.
742 Service {
743 #[command(subcommand)]
744 action: ServiceAction,
745 },
746 /// Inspect or toggle the structured diagnostic trace
747 /// (`$WIRE_HOME/state/wire/diag.jsonl`). Off by default. Enable per
748 /// process via `WIRE_DIAG=1`, or per-machine via `wire diag enable`
749 /// (writes the file knob a running daemon picks up automatically).
750 Diag {
751 #[command(subcommand)]
752 action: DiagAction,
753 },
754 /// Claim your persona on a relay's handle directory. Anyone can then
755 /// reach this agent by `<persona>@<relay-domain>` via the relay's
756 /// `.well-known/wire/agent` endpoint. FCFS; same-DID re-claims allowed.
757 ///
758 /// ONE-NAME RULE (v0.13.1): the claimed handle is always your DID-derived
759 /// persona. The `nick` arg is vestigial — if it differs it is ignored
760 /// (like the typed name `wire init` / `wire up` already ignore), so your
761 /// phonebook entry can never drift from your agent-card handle.
762 ///
763 /// v0.13.1: hidden — `wire up` claims your persona for you. Kept callable
764 /// (idempotent re-claim) but not a user verb; there is no nick to choose.
765 #[command(hide = true)]
766 Claim {
767 /// Vestigial: ignored if it differs from your DID-derived persona.
768 nick: String,
769 /// Relay to claim the nick on. Default = relay our slot is on.
770 #[arg(long)]
771 relay: Option<String>,
772 /// Public URL the relay should advertise to resolvers (default = relay).
773 #[arg(long)]
774 public_url: Option<String>,
775 /// v0.5.19 (#9.1): opt out of the relay's bulk `/v1/handles`
776 /// directory listing. The handle stays claimed (FCFS still
777 /// applies) and direct `.well-known/wire/agent?handle=X` lookup
778 /// still resolves, so peers you share the handle with out-of-band
779 /// can still pair. Bulk scrapers / phonebook crawlers will not
780 /// see the nick. Use this for handles meant for known-peer
781 /// pairing only — see issue #9.
782 #[arg(long)]
783 hidden: bool,
784 #[arg(long)]
785 json: bool,
786 },
787 /// Edit profile fields (display_name, emoji, motto, vibe, pronouns,
788 /// avatar_url, handle, now). Re-signs the agent-card atomically.
789 ///
790 /// Examples:
791 /// wire profile set motto "compiles or dies trying"
792 /// wire profile set emoji "🦀"
793 /// wire profile set vibe '["rust","late-night","no-async-please"]'
794 /// wire profile set handle "coffee-ghost@anthropic.dev"
795 /// wire profile get
796 Profile {
797 #[command(subcommand)]
798 action: ProfileAction,
799 },
800 /// Mint a one-paste invite URL. Anyone with this URL can pair to us in a
801 /// single step (no SAS digits, no code typing). Auto-inits + auto-allocates
802 /// a relay slot on first use. Default TTL 24h, single-use.
803 #[command(hide = true)] // v0.9 deprecated
804 Invite {
805 /// Override the relay URL for first-time auto-allocation.
806 #[arg(long, default_value = "https://wireup.net")]
807 relay: String,
808 /// Invite lifetime in seconds (default 86400 = 24h).
809 #[arg(long, default_value_t = 86_400)]
810 ttl: u64,
811 /// Number of distinct peers that can accept this invite before it's
812 /// consumed (default 1).
813 #[arg(long, default_value_t = 1)]
814 uses: u32,
815 /// Register the invite at the relay's short-URL endpoint and print
816 /// a `curl ... | sh` one-liner the peer can run on a fresh machine.
817 /// Installs wire if missing, then accepts the invite, then pairs.
818 #[arg(long)]
819 share: bool,
820 /// Emit JSON.
821 #[arg(long)]
822 json: bool,
823 },
824 /// Accept a pending-inbound pair request by character
825 /// nickname or card handle.
826 ///
827 /// v0.9.4: the URL-vs-name smart-dispatch from v0.9 is gone. To
828 /// accept a federation invite URL use `wire accept-invite <URL>`
829 /// (split out as an explicit verb to eliminate the input-shape
830 /// ambiguity). `wire accept <URL>` still works for back-compat
831 /// but emits a deprecation banner pointing at `accept-invite`.
832 Accept {
833 /// Pending peer name (character nickname or card handle).
834 target: String,
835 /// Emit JSON.
836 #[arg(long)]
837 json: bool,
838 },
839 /// Accept a federation invite URL minted by `wire invite`.
840 /// Pins issuer, sends signed card to issuer's slot. Auto-inits +
841 /// auto-allocates as needed.
842 ///
843 /// Split out from `wire accept` to eliminate the URL-vs-name
844 /// smart-dispatch ambiguity (peer handles can legitimately collide
845 /// with URL-shaped strings; the explicit verb removes the inference).
846 #[command(alias = "invite-accept")]
847 AcceptInvite {
848 /// The full invite URL (starts with `wire://pair?v=1&inv=...`).
849 url: String,
850 /// Emit JSON.
851 #[arg(long)]
852 json: bool,
853 },
854 /// Refuse a pending-inbound pair request without pairing.
855 Reject {
856 /// Peer name (character nickname or handle) from `wire pending`.
857 peer: String,
858 /// Emit JSON.
859 #[arg(long)]
860 json: bool,
861 },
862 /// Block a peer DID so it can never be org-auto-paired or surface an
863 /// org-notify prompt (RFC-001 §T16 rogue-admin containment).
864 ///
865 /// Pass a **session DID** (`did:wire:<handle>-<8hex>`) to mute one session,
866 /// or an **operator DID** (`did:wire:op:<handle>-<32hex>`) to mute every
867 /// session that operator runs — the lever for cutting off a single
868 /// adversary a compromised org admin vouched into the roster, without
869 /// leaving the org. Local-only; idempotent; survives roster epoch bumps.
870 ///
871 /// A block gates the org-easing path, NOT a deliberate bilateral SAS pair:
872 /// if you knowingly `wire dial` + SAS-verify a blocked peer, that explicit
873 /// gesture wins. Unblock with `wire unblock-peer <did>`.
874 BlockPeer {
875 /// The DID to block (session `did:wire:…` or operator `did:wire:op:…`).
876 did: String,
877 /// Optional note recorded alongside the block (why / who).
878 #[arg(long)]
879 note: Option<String>,
880 /// Emit JSON.
881 #[arg(long)]
882 json: bool,
883 },
884 /// Remove a DID from the local block-list (undo `wire block-peer`).
885 UnblockPeer {
886 /// The DID to unblock.
887 did: String,
888 /// Emit JSON.
889 #[arg(long)]
890 json: bool,
891 },
892 /// List the DIDs on the local block-list (RFC-001 §T16).
893 Blocked {
894 /// Emit JSON.
895 #[arg(long)]
896 json: bool,
897 },
898 /// Watch the inbox for new verified events and fire an OS notification per
899 /// event. Long-running; background under systemd / `&` / tmux. Cursor is
900 /// persisted to `$WIRE_HOME/state/wire/notify.cursor` so restarts don't
901 /// re-emit history.
902 Notify {
903 /// Poll interval in seconds.
904 #[arg(long, default_value_t = 2)]
905 interval: u64,
906 /// Only notify for events from this peer (handle, no did: prefix).
907 #[arg(long)]
908 peer: Option<String>,
909 /// Run a single sweep and exit (useful for cron / tests).
910 #[arg(long)]
911 once: bool,
912 /// Suppress the OS notification call; print one JSON line per event to
913 /// stdout instead (for piping into other tooling or smoke-testing
914 /// without a desktop session).
915 #[arg(long)]
916 json: bool,
917 },
918 /// Silence (or re-enable) all wire desktop toasts. Persistent across
919 /// daemon restarts via a file at `<config_dir>/quiet`. `wire quiet on`
920 /// = silence; `wire quiet off` = restore; `wire quiet status` = report.
921 /// Same effect as exporting `WIRE_NO_TOASTS=1` (the env-var override
922 /// is for launchd contexts where the daemon's env isn't writable from
923 /// the operator's shell).
924 Quiet {
925 #[command(subcommand)]
926 action: QuietAction,
927 },
928}
929
930#[derive(Subcommand, Debug)]
931pub enum QuietAction {
932 /// Touch `<config_dir>/quiet` — silences every wire desktop toast
933 /// (pair_drop, monitor, inbox). Idempotent.
934 On,
935 /// Remove `<config_dir>/quiet` — re-enables toasts. Idempotent (no
936 /// error if already off / file absent).
937 Off,
938 /// Report current state: `on` (file present) / `off` (file absent) /
939 /// `forced-on-by-env` (`WIRE_NO_TOASTS=1` in env, overrides file).
940 Status {
941 /// Emit `{"state": "...", "via": "file"|"env"|"none"}` JSON
942 /// instead of the human one-liner.
943 #[arg(long)]
944 json: bool,
945 },
946}
947
948#[derive(Subcommand, Debug)]
949pub enum DiagAction {
950 /// Tail the last N entries from diag.jsonl.
951 Tail {
952 #[arg(long, default_value_t = 20)]
953 limit: usize,
954 #[arg(long)]
955 json: bool,
956 },
957 /// Flip the file-based knob ON. Running daemons pick this up on
958 /// the next emit call without restart.
959 Enable,
960 /// Flip the file-based knob OFF.
961 Disable,
962 /// Report whether diag is currently enabled + the file's size.
963 Status {
964 #[arg(long)]
965 json: bool,
966 },
967}
968
969/// `wire enroll …` — mint the operator/org identities + certs the offline
970/// org-membership layer (RFC-001) consumes. Keys are stored 0600 alongside
971/// `private.key`. (Publishing these claims on the agent's own card — the
972/// card-emit integration — is a separate follow-up.)
973#[derive(Subcommand, Debug)]
974pub enum EnrollCommand {
975 /// Mint this machine's operator root key (`op.key`) and print its `op_did`.
976 Op {
977 /// Operator handle (display only; the op_did commits to the key).
978 #[arg(long, default_value = "operator")]
979 handle: String,
980 #[arg(long)]
981 json: bool,
982 },
983 /// Mint an organization root key and print its `org_did` + `org_pubkey`.
984 OrgCreate {
985 /// Org handle (display only; the org_did commits to the key).
986 #[arg(long)]
987 handle: String,
988 #[arg(long)]
989 json: bool,
990 },
991 /// Issue a membership cert: the named org signs an operator's `op_did`.
992 /// Prints the `{org_did, org_pubkey, member_cert}` bundle for the operator
993 /// to add to their card's `org_memberships[]`.
994 OrgAddMember {
995 /// The operator DID to vouch for (`did:wire:op:…`).
996 op_did: String,
997 /// Which org signs (its `org_did`).
998 #[arg(long)]
999 org: String,
1000 #[arg(long)]
1001 json: bool,
1002 },
1003 /// Rebuild the agent card with the **current** enrollment state and
1004 /// republish to the phonebook. Closes the enroll-after-`init` DX gap:
1005 /// claims are normally attached at card-build time, but an operator who
1006 /// enrolls AFTER `init` has a stored card that pre-dates the claims. Run
1007 /// this once after `wire enroll op` / `org-add-member` to surface them.
1008 /// Idempotent: not-enrolled rebuilds a claims-free card; not-bound prints
1009 /// "local only".
1010 Republish {
1011 #[arg(long)]
1012 json: bool,
1013 },
1014 /// Ingest a membership cert handed to this operator by an org owner.
1015 ///
1016 /// Closes the DX gap surfaced in #127 (slate-lotus 2026-05-30 audit):
1017 /// `wire enroll org-add-member` printed an `{org_did, org_pubkey,
1018 /// member_cert}` bundle but the receiver had no verb to store it —
1019 /// joining an org required hand-editing
1020 /// `<config>/wire/memberships.json`. This verb wraps the existing
1021 /// `config::add_membership` helper + verifies the cert against
1022 /// `org_pubkey` and this operator's `op_did` before storing, so a
1023 /// malformed / wrong-key bundle fails loudly instead of corrupting
1024 /// the next `wire enroll republish`.
1025 ///
1026 /// Accepts either a single `--bundle '<json>'` (the verbatim
1027 /// org-add-member output) or the three fields separately. Idempotent:
1028 /// re-running with the same `org_did` replaces the prior entry.
1029 AddMembership {
1030 /// Verbatim `org-add-member` output (overrides individual flags
1031 /// when set). Shape: `{"org_did":"…","org_pubkey":"…","member_cert":"…"}`.
1032 #[arg(long)]
1033 bundle: Option<String>,
1034 /// Required when `--bundle` is not set.
1035 #[arg(long)]
1036 org: Option<String>,
1037 /// Required when `--bundle` is not set. Base64.
1038 #[arg(long = "org-pubkey")]
1039 org_pubkey: Option<String>,
1040 /// Required when `--bundle` is not set. Base64-encoded Ed25519
1041 /// signature by `org_pubkey` over this operator's `op_did`.
1042 #[arg(long = "member-cert")]
1043 member_cert: Option<String>,
1044 #[arg(long)]
1045 json: bool,
1046 },
1047 /// Rotate the operator root key (RFC-001 §T20). Mints a fresh op keypair —
1048 /// which, because the op_did commits to the key, is a NEW op_did — and
1049 /// emits a succession cert: the old key signing the `old_op_did → new_op_did`
1050 /// handoff. Use after a suspected op-key compromise.
1051 ///
1052 /// After rotating you MUST re-enroll: every org you're in re-issues your
1053 /// member_cert against the new op_did (`wire enroll org-add-member
1054 /// <new_op_did>`), then `wire enroll republish`. Receiver-side automatic
1055 /// trust-migration from the succession cert is deferred (T20); the cert +
1056 /// the new op_did are recorded in `succession.jsonl` for that follow-up.
1057 RotateOpKey {
1058 #[arg(long)]
1059 json: bool,
1060 },
1061 /// Rotate an organization root key (RFC-001 §T19). Mints a fresh org keypair
1062 /// (a NEW org_did) and emits a succession cert (old org key signs the
1063 /// `old_org_did → new_org_did` handoff). Use after a suspected org-key
1064 /// compromise.
1065 ///
1066 /// After rotating you re-issue every member_cert with the new key and
1067 /// republish the org's DNS-TXT binding to the new org_did. The new key is
1068 /// stored under the new org_did; the old key file is left in place for you
1069 /// to delete.
1070 RotateOrgKey {
1071 /// The current `org_did` to rotate (from `wire enroll org-create`).
1072 org_did: String,
1073 #[arg(long)]
1074 json: bool,
1075 },
1076}
1077
1078/// `wire org …` — trust organizations by their domain (RFC-001 §2 DNS-TXT
1079/// floor). Binding resolves `_wire-org.<domain>` to an `org_did` and records a
1080/// per-org inbound policy; a peer with a verified `member_cert` for a bound org
1081/// then reaches `ORG_VERIFIED` under the chosen mode.
1082#[derive(Subcommand, Debug)]
1083pub enum OrgCommand {
1084 /// Resolve `_wire-org.<domain>` (DNS-TXT, over DoH) and trust the org it
1085 /// binds. The org's identity is now rooted in a domain it demonstrably
1086 /// controls — not a bare keypair.
1087 Bind {
1088 /// The org's domain, e.g. `acme.com`.
1089 domain: String,
1090 /// Inbound mode for members: `notify` (default — one tap to
1091 /// ORG_VERIFIED) or `auto` (Option A — pin ORG_VERIFIED with no tap;
1092 /// amplifies a rogue-admin's blast radius, so opt in deliberately).
1093 #[arg(long, default_value = "notify")]
1094 mode: String,
1095 /// Emit JSON.
1096 #[arg(long)]
1097 json: bool,
1098 },
1099 /// List the organizations currently trusted (org_did + inbound mode).
1100 List {
1101 /// Emit JSON.
1102 #[arg(long)]
1103 json: bool,
1104 },
1105 /// Stop trusting an organization (remove its per-org policy by `org_did`).
1106 Forget {
1107 /// The `org_did` to forget (from `wire org list`).
1108 org_did: String,
1109 /// Emit JSON.
1110 #[arg(long)]
1111 json: bool,
1112 },
1113}
1114
1115#[derive(Subcommand, Debug)]
1116pub enum IdentityCommand {
1117 /// Print the current character (DID-derived, the only name).
1118 /// Equivalent to `wire whoami --short` but scoped here for grouping.
1119 Show {
1120 #[arg(long)]
1121 json: bool,
1122 },
1123 /// List all identities on this machine — one row per session, with
1124 /// each session's character, DID, federation handle, and cwd. Same
1125 /// shape as `wire session list`, scoped here for the v0.7+ noun-
1126 /// CLI surface.
1127 List {
1128 #[arg(long)]
1129 json: bool,
1130 },
1131 /// Promote this identity to FEDERATION lifecycle: claim your persona on
1132 /// the relay so peers can `wire dial <persona>@<relay-domain>` you.
1133 /// Re-claims with current display fields so the relay always serves the
1134 /// latest signed card. Equivalent to `wire claim`.
1135 ///
1136 /// v0.13.1: hidden — `wire up` publishes your persona for you, and the
1137 /// nick is vestigial (one-name rule). Kept callable for re-publish.
1138 #[command(hide = true)]
1139 Publish {
1140 /// Vestigial: ignored; your handle is your DID-derived persona.
1141 nick: String,
1142 /// Override the relay URL. Defaults to the session's bound relay
1143 /// from `wire init --relay <url>`. Public relay if unset.
1144 #[arg(long)]
1145 relay: Option<String>,
1146 /// Public-facing URL for the agent-card location (when the relay
1147 /// is behind a CDN with a different public domain).
1148 #[arg(long, alias = "public")]
1149 public_url: Option<String>,
1150 /// Skip listing in the relay's public phonebook. The card is
1151 /// still claimable + reachable; just doesn't appear in
1152 /// `wireup.net/phonebook` for stranger-discovery.
1153 #[arg(long)]
1154 hidden: bool,
1155 #[arg(long)]
1156 json: bool,
1157 },
1158 /// Destroy a session entirely — keys, agent-card, relay state, daemon.
1159 /// Equivalent to `wire session destroy <name>`, scoped here for the
1160 /// noun-CLI surface. Requires `--force` (the underlying command does).
1161 Destroy {
1162 /// Session name to destroy (use `wire identity list` to see).
1163 name: String,
1164 /// Bypass the confirmation prompt.
1165 #[arg(long)]
1166 force: bool,
1167 #[arg(long)]
1168 json: bool,
1169 },
1170 /// Create an identity in an EXPLICIT lifecycle state (vs. the
1171 /// implicit `wire init` + `wire claim` flow).
1172 /// v0.7.0-alpha.20 closes the v0.7+ identity-first noun-CLI.
1173 ///
1174 /// `--anonymous` puts the identity in a tmpdir (auto-cleanup on
1175 /// next reboot). In-memory semantics not yet supported — the
1176 /// pragmatic shape is "tmpdir + sentinel + register-for-cleanup."
1177 /// For pure-RAM identities, see v1.0 vision.
1178 ///
1179 /// `--local` is the explicit form of today's default; identity
1180 /// persists to the machine-wide sessions root.
1181 Create {
1182 /// Session name. Defaults to derived from cwd (anonymous mode
1183 /// uses a random name).
1184 #[arg(long)]
1185 name: Option<String>,
1186 /// Create an ANONYMOUS identity (tmpdir-backed, dies on
1187 /// reboot, no federation). Mutually exclusive with --local.
1188 #[arg(long, conflicts_with = "local")]
1189 anonymous: bool,
1190 /// Create a LOCAL identity (machine-persistent, no federation).
1191 /// Default — explicit flag for clarity.
1192 #[arg(long)]
1193 local: bool,
1194 #[arg(long)]
1195 json: bool,
1196 },
1197 /// Promote an ANONYMOUS identity to LOCAL — move from tmpdir to
1198 /// the machine-wide sessions root + register in the cwd map.
1199 /// After persist, the identity survives reboot.
1200 /// v0.7.0-alpha.20.
1201 Persist {
1202 /// The anonymous identity's name (from `wire identity list`).
1203 name: String,
1204 /// Optional rename during persist. Default: keep the anon name.
1205 #[arg(long = "as", value_name = "NEW_NAME")]
1206 as_name: Option<String>,
1207 #[arg(long)]
1208 json: bool,
1209 },
1210 /// Demote an identity ONE level in the lifecycle:
1211 /// federation → local: removes the relay slot binding but keeps
1212 /// the keypair + agent-card. Operator can later re-publish with
1213 /// `wire identity publish`. v0.7.0-alpha.20.
1214 ///
1215 /// (local → anonymous is not exposed; the safer flow is destroy +
1216 /// recreate, since "demoting" a persistent identity to ephemeral
1217 /// has surprising semantics — what about the keypair? what about
1218 /// pinned peers? Better to be explicit with destroy.)
1219 Demote {
1220 /// Session name to demote.
1221 name: String,
1222 #[arg(long)]
1223 json: bool,
1224 },
1225}
1226
1227#[derive(Subcommand, Debug)]
1228pub enum SessionCommand {
1229 /// Bootstrap a new isolated session in this machine's sessions root.
1230 /// With no name, derives one from `basename(cwd)` and caches it in
1231 /// the registry so re-running from the same project reuses it.
1232 /// Runs `init` + `claim` + spawns a session-local daemon, all inside
1233 /// the new session's WIRE_HOME. Output includes the `export
1234 /// WIRE_HOME=...` line operators paste into their shell to activate
1235 /// it.
1236 New {
1237 /// Optional session name. Default = derived from `basename(cwd)`.
1238 name: Option<String>,
1239 /// Relay URL for the session's slot allocation + handle claim.
1240 #[arg(long, default_value = "https://wireup.net")]
1241 relay: String,
1242 /// v0.5.17: also allocate a second slot on a same-machine local
1243 /// relay (defaults to `http://127.0.0.1:8771`). Within-machine
1244 /// sister-session traffic prefers this path: zero round-trip
1245 /// latency, zero metadata exposure to the public relay. Probes
1246 /// `<local-relay>/healthz` first; silently skips if the local
1247 /// relay isn't running.
1248 #[arg(long)]
1249 with_local: bool,
1250 /// v0.5.17: override the local relay URL probed by `--with-local`.
1251 /// Default is `http://127.0.0.1:8771` to match
1252 /// `wire relay-server --bind 127.0.0.1:8771 --local-only`.
1253 #[arg(long, default_value = "http://127.0.0.1:8771")]
1254 local_relay: String,
1255 /// v0.7.0-alpha.9: also allocate a slot on a LAN-bound relay
1256 /// (must be running e.g. via `wire relay-server --bind <LAN-IP>:8771`).
1257 /// Lets other machines on the same network reach this session
1258 /// directly without round-tripping the public federation relay
1259 /// at https://wireup.net. LAN endpoint is published in the
1260 /// agent-card; opt-in per session (default off).
1261 #[arg(long)]
1262 with_lan: bool,
1263 /// v0.7.0-alpha.9: LAN-reachable relay URL (no auto-detect of
1264 /// LAN IP — operator must type the address). Example:
1265 /// `http://192.168.1.50:8771`. Required when `--with-lan` is set.
1266 #[arg(long)]
1267 lan_relay: Option<String>,
1268 /// v0.7.0-alpha.18: also allocate a slot on a Unix Domain Socket
1269 /// relay (must be running e.g. via `wire relay-server --uds
1270 /// /tmp/wire.sock`). Same-host, owner-uid-only path that
1271 /// bypasses the macOS firewall + Tailscale userspace-netstack
1272 /// class of issues entirely for sister-session traffic. UDS
1273 /// endpoint is published in the agent-card.
1274 #[arg(long)]
1275 with_uds: bool,
1276 /// v0.7.0-alpha.18: UDS socket path. Required when `--with-uds`
1277 /// is set. Example: `/tmp/wire.sock` or
1278 /// `~/.wire/local.sock`.
1279 #[arg(long)]
1280 uds_socket: Option<std::path::PathBuf>,
1281 /// Skip spawning the session-local daemon. Use when you want
1282 /// to drive sync explicitly from the agent or test rig.
1283 #[arg(long)]
1284 no_daemon: bool,
1285 /// v0.6.6: create a federation-free session — no nick claim on
1286 /// `--relay`, no federation slot allocation. Implies
1287 /// `--with-local`. The session exists only to coordinate with
1288 /// other sister sessions on this machine; it has no public
1289 /// address and cannot be reached from outside. Reserved nicks
1290 /// (`wire`, `slancha`, etc.) are allowed because nothing tries
1291 /// to publish them.
1292 #[arg(long)]
1293 local_only: bool,
1294 /// Emit JSON.
1295 #[arg(long)]
1296 json: bool,
1297 },
1298 /// List all sessions on this machine with their handle, DID,
1299 /// daemon liveness, and the cwd they're associated with.
1300 List {
1301 #[arg(long)]
1302 json: bool,
1303 },
1304 /// List sister sessions reachable via a same-machine local relay
1305 /// (v0.5.17 dual-slot). Groups sessions by the local-relay URL they
1306 /// share. Sessions without a Local-scope endpoint are listed
1307 /// separately so the operator can tell which are federation-only.
1308 /// Read-only — does not probe any relay or touch daemons.
1309 ListLocal {
1310 #[arg(long)]
1311 json: bool,
1312 },
1313 /// v0.6.0 (issue #12): mesh-pair every sister session against every
1314 /// other in O(N²) handshakes. For each unordered pair (A, B) that
1315 /// is not already paired, drives the bilateral flow end-to-end:
1316 /// `wire add` from A → B (queued + pushed), `wire accept` on
1317 /// B's side, then a final pull on A so the ack lands. Idempotent —
1318 /// re-running skips pairs already in `state.peers`.
1319 ///
1320 /// **Trust anchor:** the operator running this command owns every
1321 /// session listed in `wire session list-local` (they all live under
1322 /// the same `$WIRE_HOME/sessions/` directory the operator chose).
1323 /// That filesystem-permission boundary IS the consent for both
1324 /// sides — the bilateral SAS / network-level handshake assumes
1325 /// strangers; same-uid sister sessions are by definition not
1326 /// strangers. Cross-uid sister sessions are out of scope; today
1327 /// `wire session list-local` only enumerates this user's sessions.
1328 PairAllLocal {
1329 /// Seconds to wait between handshake stages for pair_drop /
1330 /// pair_drop_ack to propagate over the relay. Default 1s
1331 /// (local-relay is typically <100ms RTT). Bump if you see
1332 /// "pending-inbound never arrived" errors on a slow relay.
1333 #[arg(long, default_value_t = 1)]
1334 settle_secs: u64,
1335 /// Federation relay to bind each `wire add` against. Default
1336 /// `https://wireup.net`. Sister sessions should be bound to
1337 /// the same federation relay; the pair handshake routes through
1338 /// it for the .well-known resolution + pair_drop deposit.
1339 #[arg(long, default_value = "https://wireup.net")]
1340 federation_relay: String,
1341 #[arg(long)]
1342 json: bool,
1343 },
1344 /// v0.6.2 (issue #18): live view of the sister-session mesh on this
1345 /// machine. Enumerates every session in `wire session list-local`,
1346 /// walks each session's `relay.json#peers` to find which other sister
1347 /// sessions it has pinned, and probes the local relay for each edge's
1348 /// `last_pull_at_unix` to surface stale/silent peers. Text output is
1349 /// the pin matrix + per-edge health roll-up; JSON is `{sessions, edges,
1350 /// local_relay, summary}` so scripts can scrape.
1351 ///
1352 /// Read-only — does NOT touch peers or daemons, only the relay's
1353 /// public `/v1/slot/<id>/state` endpoint with the slot tokens we
1354 /// already hold. Silent on any probe failure (degrades to "no
1355 /// signal" rather than abort) so a half-broken mesh is still
1356 /// inspectable.
1357 MeshStatus {
1358 /// Threshold in seconds for "stale" classification on an edge.
1359 /// An edge whose receiver hasn't polled their slot in this long
1360 /// is flagged. Default 300s (5 min) — same as the per-send
1361 /// `phyllis` attentiveness nag.
1362 #[arg(long, default_value_t = 300)]
1363 stale_secs: u64,
1364 #[arg(long)]
1365 json: bool,
1366 },
1367 /// Print the `export WIRE_HOME=...` line for a session, so a shell
1368 /// can `eval $(wire session env <name>)` to activate it. With no
1369 /// name, resolves the cwd through the registry.
1370 Env {
1371 /// Session name. Default = derived from cwd via the registry.
1372 name: Option<String>,
1373 #[arg(long)]
1374 json: bool,
1375 },
1376 /// Identify which session the current cwd maps to in the registry.
1377 /// Prints `(none)` if cwd isn't registered — `wire session new`
1378 /// would create one.
1379 Current {
1380 #[arg(long)]
1381 json: bool,
1382 },
1383 /// Attach an existing session to the current cwd in the registry,
1384 /// so subsequent auto-detect from this cwd resolves to that session
1385 /// instead of walking up to an ancestor's binding. Use when an
1386 /// ancestor dir (e.g. `~/Source`) is already registered and is
1387 /// shadowing per-project identities for cwds beneath it. Idempotent;
1388 /// re-binding to the same name is a no-op. Re-binding to a different
1389 /// name overwrites the prior entry with a stderr warning.
1390 Bind {
1391 /// Session name to bind. Must already exist (run `wire session
1392 /// new <name>` first if not). With no name, auto-derives from
1393 /// `basename(cwd)` and errors if no session of that name exists.
1394 name: Option<String>,
1395 #[arg(long)]
1396 json: bool,
1397 },
1398 /// Tear down a session: kills its daemon (if running), deletes its
1399 /// state directory, and removes it from the registry. Requires
1400 /// `--force` because state loss is unrecoverable (keypair gone).
1401 Destroy {
1402 name: String,
1403 /// Confirm state-deleting operation.
1404 #[arg(long)]
1405 force: bool,
1406 #[arg(long)]
1407 json: bool,
1408 },
1409}
1410
1411/// v0.6.3: top-level `wire mesh` verbs. Each verb operates on the current
1412/// session's view of the pinned peer set. `status` is the read-only
1413/// observability primitive (alias for `wire session mesh-status`);
1414/// Group-chat verbs (v0.13.3). Membership is a creator-signed roster
1415/// (`src/group.rs`); send fans a signed message over the member set.
1416#[derive(Subcommand, Debug)]
1417pub enum GroupCommand {
1418 /// Create a new group — you become the creator + sole member, roster signed.
1419 Create {
1420 /// Group name (human label).
1421 name: String,
1422 #[arg(long)]
1423 json: bool,
1424 },
1425 /// Add a bilaterally-VERIFIED pinned peer to a group you created (Member tier).
1426 Add {
1427 /// Group id or name.
1428 group: String,
1429 /// Peer handle (must be a VERIFIED pinned peer).
1430 peer: String,
1431 #[arg(long)]
1432 json: bool,
1433 },
1434 /// Send a message to every other member of a group (signed fan-out).
1435 Send {
1436 /// Group id or name.
1437 group: String,
1438 /// Message text.
1439 message: String,
1440 #[arg(long)]
1441 json: bool,
1442 },
1443 /// Show recent messages received for a group.
1444 Tail {
1445 /// Group id or name.
1446 group: String,
1447 /// Max messages to show.
1448 #[arg(long, default_value_t = 20)]
1449 limit: usize,
1450 #[arg(long)]
1451 json: bool,
1452 },
1453 /// List your groups + their members and tiers.
1454 List {
1455 #[arg(long)]
1456 json: bool,
1457 },
1458 /// Mint a shareable join code for a group (a self-contained token carrying
1459 /// the room coords + signed roster). Anyone you give it to can `wire group
1460 /// join <code>` to enter the room at Introduced tier. The code IS the room
1461 /// key — share it only with people you want in the room.
1462 Invite {
1463 /// Group id or name.
1464 group: String,
1465 #[arg(long)]
1466 json: bool,
1467 },
1468 /// Join a group from a code minted by `wire group invite`. Materializes the
1469 /// room locally, pins the existing members on the creator's vouch, and
1470 /// announces you to the room so members can verify your messages.
1471 Join {
1472 /// The `wire-group:` code (or bare base64 payload).
1473 code: String,
1474 #[arg(long)]
1475 json: bool,
1476 },
1477}
1478
1479/// `broadcast` fans a signed event to every pinned peer in one call.
1480#[derive(Subcommand, Debug)]
1481pub enum MeshCommand {
1482 /// Alias for `wire session mesh-status`. Reports the N×N pin matrix +
1483 /// per-edge health roll-up across every sister session on this machine.
1484 Status {
1485 /// Threshold in seconds for "stale" classification on an edge.
1486 #[arg(long, default_value_t = 300)]
1487 stale_secs: u64,
1488 #[arg(long)]
1489 json: bool,
1490 },
1491 /// Fan one signed event to every pinned peer. Each peer receives a
1492 /// distinct `event_id` but every copy shares the same `broadcast_id`
1493 /// UUID so receivers can correlate them as a single broadcast.
1494 ///
1495 /// `--scope local` (default) only fans to peers reachable via a same-
1496 /// machine local relay. `--scope federation` only to public-relay
1497 /// peers. `--scope both` to every pinned peer.
1498 ///
1499 /// `--exclude <peer>` (repeatable) skips a specific handle. Useful
1500 /// for "ack-loop" prevention: a peer responding to a broadcast can
1501 /// exclude its own broadcaster when re-broadcasting.
1502 ///
1503 /// Body parsing follows `wire send`: literal string, `@/path` reads a
1504 /// file, `-` reads stdin (JSON if parseable, else literal).
1505 ///
1506 /// Pinned-peers-only by construction. NEVER broadcasts to non-paired
1507 /// peers — that would re-introduce the phonebook-scrape risk closed
1508 /// in v0.5.14 (T8).
1509 Broadcast {
1510 /// Event kind: `claim` (default), `decision`, `question`, `ack`,
1511 /// `heartbeat`. Same vocabulary as `wire send`.
1512 #[arg(long, default_value = "claim")]
1513 kind: String,
1514 /// `local`, `federation`, or `both`. Default `local`.
1515 #[arg(long, default_value = "local")]
1516 scope: String,
1517 /// Skip a specific peer handle. Repeatable.
1518 #[arg(long)]
1519 exclude: Vec<String>,
1520 /// Drop the broadcast event ID from the relay-side attentiveness
1521 /// nag (`phyllis`) — useful when broadcasting to many peers and
1522 /// the per-peer "X hasn't pulled in 5min" lines would be noise.
1523 #[arg(long)]
1524 noreply: bool,
1525 /// Body — string, `@/path` for a file, or `-` for stdin.
1526 body: String,
1527 #[arg(long)]
1528 json: bool,
1529 },
1530 /// v0.6.4 (issue #20): assign role tags to sister sessions for
1531 /// capability-aware addressing. Stored as `profile.role` on the
1532 /// signed agent-card — propagates over the existing pair / .well-
1533 /// known plumbing, no new persistence.
1534 ///
1535 /// First slice of the Layer-2 capability metadata umbrella (#13).
1536 /// `wire mesh route` (issue #21) will consume these tags to pick
1537 /// the right sister for a task.
1538 Role {
1539 #[command(subcommand)]
1540 action: MeshRoleAction,
1541 },
1542 /// v0.6.5 (issue #21): capability-match routing. Resolve a role tag
1543 /// to one sister session and deliver an event to that one peer.
1544 /// Closes the orchestration-primitive arc opened in v0.6.0 — operators
1545 /// can now address "the reviewer" instead of hard-coding a handle.
1546 ///
1547 /// Strategies:
1548 /// - `round-robin` (default): per-role cursor, persisted at
1549 /// `<state_dir>/mesh-route-cursor.json`. Alternates fairly.
1550 /// - `first`: alphabetically-first matching sister. Deterministic.
1551 /// - `random`: uniform random among matches. Stateless.
1552 ///
1553 /// Pinned-peers-only by construction (same posture as `broadcast`).
1554 /// Caller must already have the target sister pinned in
1555 /// `state.peers` — otherwise we can't sign + push. Run
1556 /// `wire session pair-all-local` first if the mesh isn't wired.
1557 Route {
1558 /// Role to match (operator-defined tag from `wire mesh role set`).
1559 role: String,
1560 /// `round-robin` (default), `first`, or `random`.
1561 #[arg(long, default_value = "round-robin")]
1562 strategy: String,
1563 /// Skip a specific sister handle. Repeatable.
1564 #[arg(long)]
1565 exclude: Vec<String>,
1566 /// Event kind: `claim` (default), `decision`, `question`, `ack`,
1567 /// `heartbeat`. Same vocabulary as `wire send` / broadcast.
1568 #[arg(long, default_value = "claim")]
1569 kind: String,
1570 /// Body — string, `@/path` for a file, or `-` for stdin.
1571 body: String,
1572 #[arg(long)]
1573 json: bool,
1574 },
1575}
1576
1577/// v0.6.4: subcommands of `wire mesh role`.
1578#[derive(Subcommand, Debug)]
1579pub enum MeshRoleAction {
1580 /// Assign self to a role. Role is a free-form ASCII string
1581 /// (alphanumeric + `-` + `_`, max 32 chars). Operators agree on
1582 /// the vocabulary out-of-band — common starters: `planner`,
1583 /// `executor`, `reviewer`, `coder`, `tester`, `dispatcher`.
1584 Set {
1585 role: String,
1586 #[arg(long)]
1587 json: bool,
1588 },
1589 /// Read self or a peer's role. With no arg, prints self. With a
1590 /// handle, reads from the peer's pinned agent-card.
1591 Get {
1592 peer: Option<String>,
1593 #[arg(long)]
1594 json: bool,
1595 },
1596 /// List roles across every sister session on this machine. Reads
1597 /// each session's agent-card by path — no network, no env mutation.
1598 List {
1599 #[arg(long)]
1600 json: bool,
1601 },
1602 /// Remove self from any assigned role. Re-signs the card with
1603 /// `profile.role: null`.
1604 Clear {
1605 #[arg(long)]
1606 json: bool,
1607 },
1608}
1609
1610#[derive(Subcommand, Debug)]
1611pub enum ServiceAction {
1612 /// Write the launchd plist (macOS) or systemd user unit (linux) and
1613 /// load it. Idempotent — re-running re-bootstraps an existing service.
1614 ///
1615 /// v0.5.22: with no flags, installs the `wire daemon` (your sync
1616 /// process). Pass `--local-relay` to install the loopback relay
1617 /// (`wire relay-server --bind 127.0.0.1:8771 --local-only`) — the
1618 /// transport sister-Claudes use to coordinate on the same machine
1619 /// (v0.5.17 dual-slot). The two services have distinct labels +
1620 /// log files, so you can install both.
1621 Install {
1622 /// Install the local-relay service instead of the daemon.
1623 #[arg(long)]
1624 local_relay: bool,
1625 #[arg(long)]
1626 json: bool,
1627 },
1628 /// Unload + delete the service unit. Daemon keeps running until the
1629 /// next reboot or `wire upgrade`; this only changes the boot-time
1630 /// behaviour.
1631 Uninstall {
1632 /// Uninstall the local-relay service instead of the daemon.
1633 #[arg(long)]
1634 local_relay: bool,
1635 #[arg(long)]
1636 json: bool,
1637 },
1638 /// Report whether the unit is installed + active.
1639 Status {
1640 /// Show status of the local-relay service instead of the daemon.
1641 #[arg(long)]
1642 local_relay: bool,
1643 #[arg(long)]
1644 json: bool,
1645 },
1646}
1647
1648#[derive(Subcommand, Debug)]
1649pub enum ResponderCommand {
1650 /// Publish this agent's auto-responder health.
1651 Set {
1652 /// One of: online, offline, oauth_locked, rate_limited, degraded.
1653 status: String,
1654 /// Optional operator-facing reason.
1655 #[arg(long)]
1656 reason: Option<String>,
1657 /// Emit JSON.
1658 #[arg(long)]
1659 json: bool,
1660 },
1661 /// Read responder health for self, or for a paired peer.
1662 Get {
1663 /// Optional peer handle; omitted means this agent's own slot.
1664 peer: Option<String>,
1665 /// Emit JSON.
1666 #[arg(long)]
1667 json: bool,
1668 },
1669}
1670
1671#[derive(Subcommand, Debug)]
1672pub enum ProfileAction {
1673 /// Set a profile field. Field names: display_name, emoji, motto, vibe,
1674 /// pronouns, avatar_url, handle, now. Values are strings except `vibe`
1675 /// (JSON array) and `now` (JSON object).
1676 Set {
1677 field: String,
1678 value: String,
1679 #[arg(long)]
1680 json: bool,
1681 },
1682 /// Show all profile fields. Equivalent to `wire whois`.
1683 Get {
1684 #[arg(long)]
1685 json: bool,
1686 },
1687 /// Clear a profile field.
1688 Clear {
1689 field: String,
1690 #[arg(long)]
1691 json: bool,
1692 },
1693}
1694
1695/// Entry point — parse and dispatch.
1696pub fn run() -> Result<()> {
1697 // v0.6.7: when WIRE_HOME isn't explicitly set, look up the cwd in
1698 // the session registry and adopt that session's home for this
1699 // process. Brings the CLI to parity with the v0.6.1 MCP auto-
1700 // detect — `wire whoami` / `wire monitor` from a project cwd now
1701 // resolve to that project's session identity, not the machine
1702 // default. Suppress the stderr line with `WIRE_QUIET_AUTOSESSION=1`.
1703 //
1704 // MUST run before any thread spawn — call it FIRST, before
1705 // `Cli::parse` (which uses clap internals only) and before any
1706 // command dispatch (which may spawn workers).
1707 crate::session::maybe_adopt_session_wire_home("cli");
1708 let cli = Cli::parse();
1709 match cli.command {
1710 Command::Init {
1711 relay,
1712 offline,
1713 json,
1714 } => cmd_init(relay.as_deref(), offline, json),
1715 Command::Status { peer, json } => {
1716 if let Some(peer) = peer {
1717 status::cmd_status_peer(&peer, json)
1718 } else {
1719 status::cmd_status(json)
1720 }
1721 }
1722 Command::Whoami {
1723 json,
1724 short,
1725 colored,
1726 } => identity::cmd_whoami(json_default(json), short, colored),
1727 Command::Peers { json } => comms::cmd_peers(json_default(json)),
1728 Command::Here { json } => comms::cmd_here(json_default(json)),
1729 Command::Demo { json } => demo::cmd_demo(json_default(json)),
1730 Command::Completions { shell } => {
1731 // v0.9.5: print shell completion script to stdout. Operator
1732 // pipes into their shell's completion dir; tab completion
1733 // covers verbs (dial, send, pending, accept, etc.) AND
1734 // their flags. Peer-name dynamic completion is a future
1735 // shell-side enhancement; clap_complete only ships the
1736 // static grammar.
1737 use clap::CommandFactory;
1738 let mut cmd = Cli::command();
1739 clap_complete::generate(shell, &mut cmd, "wire", &mut std::io::stdout());
1740 Ok(())
1741 }
1742 Command::Pending { json } => pairing::cmd_pair_list_inbound(json_default(json)),
1743 Command::Reject { peer, json } => pairing::cmd_pair_reject(&peer, json_default(json)),
1744 Command::BlockPeer { did, note, json } => {
1745 pairing::cmd_block_peer(&did, note, json_default(json))
1746 }
1747 Command::UnblockPeer { did, json } => pairing::cmd_unblock_peer(&did, json_default(json)),
1748 Command::Blocked { json } => pairing::cmd_blocked(json_default(json)),
1749 Command::Send {
1750 peer,
1751 kind_or_body,
1752 body,
1753 deadline,
1754 no_auto_pair,
1755 queue,
1756 json,
1757 } => {
1758 // P0.S: smart-positional API. `wire send peer body` =
1759 // kind=claim. `wire send peer kind body` = explicit kind.
1760 let (kind, body) = match body {
1761 Some(real_body) => (kind_or_body, real_body),
1762 None => ("claim".to_string(), kind_or_body),
1763 };
1764 comms::cmd_send(
1765 &peer,
1766 &kind,
1767 &body,
1768 deadline.as_deref(),
1769 no_auto_pair,
1770 queue,
1771 json_default(json),
1772 )
1773 }
1774 Command::SendProject {
1775 project,
1776 body,
1777 kind,
1778 deadline,
1779 json,
1780 } => comms::cmd_send_project(
1781 &project,
1782 &kind,
1783 &body,
1784 deadline.as_deref(),
1785 json_default(json),
1786 ),
1787 Command::Project { tag, clear, json } => {
1788 identity::cmd_project(tag.as_deref(), clear, json_default(json))
1789 }
1790 Command::Dial {
1791 name,
1792 message,
1793 json,
1794 } => pairing::cmd_dial(&name, message.as_deref(), json_default(json)),
1795 Command::Tail {
1796 peer,
1797 json,
1798 limit,
1799 oldest,
1800 } => comms::cmd_tail(peer.as_deref(), json, limit, oldest),
1801 Command::Monitor {
1802 peer,
1803 json,
1804 include_handshake,
1805 interval_ms,
1806 replay,
1807 } => comms::cmd_monitor(
1808 peer.as_deref(),
1809 json,
1810 include_handshake,
1811 interval_ms,
1812 replay,
1813 ),
1814 Command::Verify { path, json } => comms::cmd_verify(&path, json),
1815 Command::Responder { command } => match command {
1816 ResponderCommand::Set {
1817 status,
1818 reason,
1819 json,
1820 } => status::cmd_responder_set(&status, reason.as_deref(), json),
1821 ResponderCommand::Get { peer, json } => {
1822 status::cmd_responder_get(peer.as_deref(), json)
1823 }
1824 },
1825 Command::Mcp => relay::cmd_mcp(),
1826 Command::RelayServer {
1827 bind,
1828 local_only,
1829 uds,
1830 } => relay::cmd_relay_server(&bind, local_only, uds.as_deref()),
1831 Command::BindRelay {
1832 url,
1833 scope,
1834 replace,
1835 migrate_pinned,
1836 json,
1837 } => relay::cmd_bind_relay(&url, scope.as_deref(), replace, migrate_pinned, json),
1838 Command::AddPeerSlot {
1839 handle,
1840 url,
1841 slot_id,
1842 slot_token,
1843 json,
1844 } => relay::cmd_add_peer_slot(&handle, &url, &slot_id, &slot_token, json),
1845 Command::Push { peer, json } => relay::cmd_push(peer.as_deref(), json),
1846 Command::Pull { json } => relay::cmd_pull(json),
1847 Command::Pin { card_file, json } => pairing::cmd_pin(&card_file, json),
1848 Command::RotateSlot { no_announce, json } => relay::cmd_rotate_slot(no_announce, json),
1849 Command::ForgetPeer {
1850 handle,
1851 purge,
1852 json,
1853 } => relay::cmd_forget_peer(&handle, purge, json),
1854 Command::Supervisor { json } => status::cmd_supervisor(json),
1855 Command::Daemon {
1856 interval,
1857 once,
1858 all_sessions,
1859 session,
1860 json,
1861 } => relay::cmd_daemon(interval, once, all_sessions, session, json),
1862 Command::Session(cmd) => cmd_session(cmd),
1863 Command::Identity { cmd } => identity::cmd_identity(cmd),
1864 Command::Mesh(cmd) => cmd_mesh(cmd),
1865 Command::Group(cmd) => cmd_group(cmd),
1866 Command::Enroll(cmd) => identity::cmd_enroll(cmd),
1867 Command::Org(cmd) => identity::cmd_org(cmd),
1868 Command::Invite {
1869 relay,
1870 ttl,
1871 uses,
1872 share,
1873 json,
1874 } => pairing::cmd_invite(&relay, ttl, uses, share, json),
1875 Command::Accept { target, json } => {
1876 // `wire accept <name>` — canonical pending-pair consent step.
1877 // URL-shaped input is no longer accepted here; use `wire accept-invite <url>`.
1878 let j = json_default(json);
1879 if target.starts_with("wire://pair?") || target.starts_with("http") {
1880 anyhow::bail!(
1881 "`wire accept` takes a peer name, not a URL. \
1882 Use `wire accept-invite {target}` to accept an invite URL."
1883 );
1884 } else {
1885 pairing::cmd_pair_accept(&target, j)
1886 }
1887 }
1888 Command::AcceptInvite { url, json } => pairing::cmd_accept(&url, json_default(json)),
1889 Command::Whois {
1890 handle,
1891 json,
1892 relay,
1893 } => {
1894 // v0.8 smart route: `wire whois <nickname>` (no `@<relay>`)
1895 // resolves through the local identity layer (pinned peers
1896 // + local sister sessions). `wire whois <nick>@<relay>`
1897 // keeps the existing federation `.well-known/wire/agent`
1898 // path. `wire whois` (no arg) prints self via the original
1899 // path. The character nickname is the canonical operator-
1900 // facing name as of v0.8 — most callers should hit the
1901 // local route.
1902 match handle.as_deref() {
1903 Some(h) if !h.contains('@') => pairing::cmd_whois_local(h, json),
1904 other => pairing::cmd_whois(other, json, relay.as_deref()),
1905 }
1906 }
1907 Command::Add {
1908 handle,
1909 relay,
1910 local_sister,
1911 json,
1912 } => pairing::cmd_add(&handle, relay.as_deref(), local_sister, json),
1913 Command::Up {
1914 relay,
1915 offline,
1916 with_local,
1917 no_local,
1918 json,
1919 } => setup::cmd_up(
1920 relay.as_deref(),
1921 offline,
1922 with_local.as_deref(),
1923 no_local,
1924 json,
1925 ),
1926 Command::Doctor {
1927 json,
1928 recent_rejections,
1929 } => status::cmd_doctor(json, recent_rejections),
1930 Command::Upgrade {
1931 check,
1932 local,
1933 restart_mcp,
1934 refresh_stale_children,
1935 json,
1936 } => upgrade::cmd_upgrade(check, local, restart_mcp, refresh_stale_children, json),
1937 Command::Service { action } => upgrade::cmd_service(action),
1938 Command::Diag { action } => status::cmd_diag(action),
1939 Command::Claim {
1940 nick,
1941 relay,
1942 public_url,
1943 hidden,
1944 json,
1945 } => identity::cmd_claim(&nick, relay.as_deref(), public_url.as_deref(), hidden, json),
1946 Command::Profile { action } => identity::cmd_profile(action),
1947 Command::Setup {
1948 apply,
1949 statusline,
1950 remove,
1951 } => {
1952 if statusline {
1953 setup::cmd_setup_statusline(apply, remove)
1954 } else {
1955 setup::cmd_setup(apply)
1956 }
1957 }
1958 Command::Notify {
1959 interval,
1960 peer,
1961 once,
1962 json,
1963 } => comms::cmd_notify(interval, peer.as_deref(), once, json),
1964 Command::Nuke {
1965 force,
1966 purge,
1967 dry_run,
1968 really_this_machine,
1969 json,
1970 } => lifecycle::cmd_nuke(force, purge, dry_run, really_this_machine, json),
1971 Command::Quiet { action } => lifecycle::cmd_quiet(action),
1972 }
1973}
1974
1975pub(crate) fn scan_jsonl_dir(dir: &std::path::Path) -> Result<Value> {
1976 if !dir.exists() {
1977 return Ok(json!({"files": 0, "events": 0}));
1978 }
1979 let mut files = 0usize;
1980 let mut events = 0usize;
1981 for entry in std::fs::read_dir(dir)? {
1982 let path = entry?.path();
1983 // v0.14.2: skip pushed-log audit files (`<peer>.pushed.jsonl`)
1984 // when scanning the outbox dir. Those are append-only audit
1985 // logs of "queued → pushed" lifecycle events (#162 fix #2);
1986 // counting them as outbox events inflates `outbox.events` in
1987 // `wire status` by orders of magnitude. Pre-fix, an operator
1988 // with 8328 events delivered across a peer's lifetime saw
1989 // "outbox: 71811 events queued" when actual unpushed work was
1990 // 11 events. Inbox scans are unaffected because the inbox dir
1991 // contains only `<peer>.jsonl`, never `.pushed.jsonl`.
1992 if path.extension().map(|x| x == "jsonl").unwrap_or(false)
1993 && !path
1994 .file_name()
1995 .and_then(|s| s.to_str())
1996 .map(|n| n.ends_with(".pushed.jsonl"))
1997 .unwrap_or(false)
1998 {
1999 files += 1;
2000 if let Ok(body) = std::fs::read_to_string(&path) {
2001 events += body.lines().filter(|l| !l.trim().is_empty()).count();
2002 }
2003 }
2004 }
2005 Ok(json!({"files": files, "events": events}))
2006}
2007
2008// (Old cmd_join stub removed — superseded by wire_dial / cmd_pair_accept.)
2009
2010/// Thin wrapper — kept as a function for tests + back-compat with
2011/// the small handful of callsites that already use this name.
2012/// Implementation moved to `crate::trust::effective_tier` so the
2013/// canonical derivation is shared with `compute_pending_push_breakdown`.
2014pub(super) fn effective_peer_tier(trust: &Value, relay_state: &Value, handle: &str) -> String {
2015 crate::trust::effective_tier(trust, relay_state, handle)
2016}
2017
2018#[cfg(test)]
2019mod tier_tests {
2020 use super::*;
2021 use serde_json::json;
2022
2023 fn trust_with(handle: &str, tier: &str) -> Value {
2024 json!({
2025 "version": 1,
2026 "agents": {
2027 handle: {
2028 "tier": tier,
2029 "did": format!("did:wire:{handle}"),
2030 "card": {"capabilities": ["wire/v3.1"]}
2031 }
2032 }
2033 })
2034 }
2035
2036 #[test]
2037 fn pending_ack_when_verified_but_no_slot_token() {
2038 // P0.Y rule: after `wire add`, trust says VERIFIED but the peer's
2039 // slot_token hasn't arrived yet. Display PENDING_ACK so the
2040 // operator knows wire send won't work yet.
2041 let trust = trust_with("willard", "VERIFIED");
2042 let relay_state = json!({
2043 "peers": {
2044 "willard": {
2045 "relay_url": "https://relay",
2046 "slot_id": "abc",
2047 "slot_token": "",
2048 }
2049 }
2050 });
2051 assert_eq!(
2052 effective_peer_tier(&trust, &relay_state, "willard"),
2053 "PENDING_ACK"
2054 );
2055 }
2056
2057 #[test]
2058 fn verified_when_slot_token_present() {
2059 let trust = trust_with("willard", "VERIFIED");
2060 let relay_state = json!({
2061 "peers": {
2062 "willard": {
2063 "relay_url": "https://relay",
2064 "slot_id": "abc",
2065 "slot_token": "tok123",
2066 }
2067 }
2068 });
2069 assert_eq!(
2070 effective_peer_tier(&trust, &relay_state, "willard"),
2071 "VERIFIED"
2072 );
2073 }
2074
2075 #[test]
2076 fn raw_tier_passes_through_for_non_verified() {
2077 // PENDING_ACK should ONLY decorate VERIFIED. UNTRUSTED stays
2078 // UNTRUSTED regardless of slot_token state.
2079 let trust = trust_with("willard", "UNTRUSTED");
2080 let relay_state = json!({
2081 "peers": {"willard": {"slot_token": ""}}
2082 });
2083 assert_eq!(
2084 effective_peer_tier(&trust, &relay_state, "willard"),
2085 "UNTRUSTED"
2086 );
2087 }
2088
2089 #[test]
2090 fn pending_ack_when_relay_state_missing_peer() {
2091 // After wire add, trust gets updated BEFORE relay_state.peers does.
2092 // If relay_state has no entry for the peer at all, the operator
2093 // still hasn't completed the bilateral pin — show PENDING_ACK.
2094 let trust = trust_with("willard", "VERIFIED");
2095 let relay_state = json!({"peers": {}});
2096 assert_eq!(
2097 effective_peer_tier(&trust, &relay_state, "willard"),
2098 "PENDING_ACK"
2099 );
2100 }
2101}
2102
2103pub(super) fn parse_kind(s: &str) -> Result<u32> {
2104 if let Ok(n) = s.parse::<u32>() {
2105 return Ok(n);
2106 }
2107 for (id, name) in crate::signing::kinds() {
2108 if *name == s {
2109 return Ok(*id);
2110 }
2111 }
2112 // Unknown name — default to kind 1 (decision) for v0.1.
2113 Ok(1)
2114}
2115
2116// ---------- session (v0.5.16) ----------
2117//
2118// Multi-session wire on one machine. See src/session.rs for the storage
2119// layout + naming rules. The CLI dispatcher here orchestrates child
2120// `wire` invocations with `WIRE_HOME` overridden to the session's dir;
2121// each session-local `init` / `claim` / `daemon` runs in its own world
2122// without cross-contamination via env vars in this process.
2123
2124// ---------- group chat (v0.13.3) ----------
2125
2126fn cmd_group(cmd: GroupCommand) -> Result<()> {
2127 match cmd {
2128 GroupCommand::Create { name, json } => group::cmd_group_create(&name, json),
2129 GroupCommand::Add { group, peer, json } => group::cmd_group_add(&group, &peer, json),
2130 GroupCommand::Send {
2131 group,
2132 message,
2133 json,
2134 } => group::cmd_group_send(&group, &message, json),
2135 GroupCommand::Tail { group, limit, json } => group::cmd_group_tail(&group, limit, json),
2136 GroupCommand::List { json } => group::cmd_group_list(json),
2137 GroupCommand::Invite { group, json } => group::cmd_group_invite(&group, json),
2138 GroupCommand::Join { code, json } => group::cmd_group_join(&code, json),
2139 }
2140}
2141
2142/// v0.6.3: top-level `wire mesh` verb dispatcher. Status aliases the
2143/// v0.6.2 session-namespaced handler; broadcast is the new primitive.
2144fn cmd_mesh(cmd: MeshCommand) -> Result<()> {
2145 match cmd {
2146 MeshCommand::Status { stale_secs, json } => cmd_session_mesh_status(stale_secs, json),
2147 MeshCommand::Broadcast {
2148 kind,
2149 scope,
2150 exclude,
2151 noreply,
2152 body,
2153 json,
2154 } => mesh::cmd_mesh_broadcast(&kind, &scope, &exclude, noreply, &body, json),
2155 MeshCommand::Role { action } => mesh::cmd_mesh_role(action),
2156 MeshCommand::Route {
2157 role,
2158 strategy,
2159 exclude,
2160 kind,
2161 body,
2162 json,
2163 } => mesh::cmd_mesh_route(&role, &strategy, &exclude, &kind, &body, json),
2164 }
2165}
2166
2167fn cmd_session(cmd: SessionCommand) -> Result<()> {
2168 match cmd {
2169 SessionCommand::New {
2170 name,
2171 relay,
2172 with_local,
2173 local_relay,
2174 with_lan,
2175 lan_relay,
2176 with_uds,
2177 uds_socket,
2178 no_daemon,
2179 local_only,
2180 json,
2181 } => session::cmd_session_new(
2182 name.as_deref(),
2183 &relay,
2184 with_local,
2185 &local_relay,
2186 with_lan,
2187 lan_relay.as_deref(),
2188 with_uds,
2189 uds_socket.as_deref(),
2190 no_daemon,
2191 local_only,
2192 json,
2193 ),
2194 SessionCommand::List { json } => session::cmd_session_list(json),
2195 SessionCommand::ListLocal { json } => session::cmd_session_list_local(json),
2196 SessionCommand::PairAllLocal {
2197 settle_secs,
2198 federation_relay,
2199 json,
2200 } => session::cmd_session_pair_all_local(settle_secs, &federation_relay, json),
2201 SessionCommand::MeshStatus { stale_secs, json } => {
2202 cmd_session_mesh_status(stale_secs, json)
2203 }
2204 SessionCommand::Env { name, json } => session::cmd_session_env(name.as_deref(), json),
2205 SessionCommand::Current { json } => session::cmd_session_current(json),
2206 SessionCommand::Bind { name, json } => cmd_session_bind(name.as_deref(), json),
2207 SessionCommand::Destroy { name, force, json } => {
2208 session::cmd_session_destroy(&name, force, json)
2209 }
2210 }
2211}
2212
2213fn cmd_session_bind(name_arg: Option<&str>, json: bool) -> Result<()> {
2214 let cwd = std::env::current_dir().with_context(|| "reading cwd")?;
2215 let cwd_str = crate::session::normalize_cwd_key(&cwd);
2216
2217 let resolved_name = match name_arg {
2218 Some(n) => crate::session::sanitize_name(n),
2219 None => crate::session::sanitize_name(
2220 cwd.file_name()
2221 .and_then(|s| s.to_str())
2222 .ok_or_else(|| anyhow!("cwd has no basename to derive session name from"))?,
2223 ),
2224 };
2225
2226 let session_home = crate::session::session_dir(&resolved_name)?;
2227 if !session_home.exists() {
2228 bail!(
2229 "session `{resolved_name}` does not exist (looked at {}). Create it first with `wire session new {resolved_name}` or pass an existing name.",
2230 session_home.display()
2231 );
2232 }
2233
2234 let prior = crate::session::read_registry()
2235 .ok()
2236 .and_then(|r| r.by_cwd.get(&cwd_str).cloned());
2237 if prior.as_deref() == Some(resolved_name.as_str()) {
2238 if json {
2239 println!(
2240 "{}",
2241 serde_json::to_string(&json!({
2242 "cwd": cwd_str,
2243 "session": resolved_name,
2244 "changed": false,
2245 }))?
2246 );
2247 } else {
2248 println!("cwd `{cwd_str}` already bound to session `{resolved_name}` (no change)");
2249 }
2250 return Ok(());
2251 }
2252 if let Some(prior_name) = &prior {
2253 eprintln!(
2254 "wire session bind: cwd `{cwd_str}` was bound to `{prior_name}`; overwriting with `{resolved_name}`."
2255 );
2256 }
2257
2258 crate::session::update_registry(|reg| {
2259 reg.by_cwd.insert(cwd_str.clone(), resolved_name.clone());
2260 Ok(())
2261 })?;
2262
2263 if json {
2264 println!(
2265 "{}",
2266 serde_json::to_string(&json!({
2267 "cwd": cwd_str,
2268 "session": resolved_name,
2269 "changed": true,
2270 "previous": prior,
2271 }))?
2272 );
2273 } else {
2274 println!("bound cwd `{cwd_str}` → session `{resolved_name}`");
2275 println!("(next `wire` invocation from this cwd will auto-detect into this session)");
2276 }
2277 Ok(())
2278}
2279
2280pub(super) fn run_wire_with_home(
2281 session_home: &std::path::Path,
2282 args: &[&str],
2283) -> Result<std::process::ExitStatus> {
2284 let bin = std::env::current_exe().with_context(|| "locating self exe")?;
2285 let status = std::process::Command::new(&bin)
2286 .env("WIRE_HOME", session_home)
2287 .env_remove("RUST_LOG")
2288 // v0.7.0-alpha.2: subprocess MUST NOT recursively auto-init.
2289 // We already own the session; nested init would clobber state.
2290 .env("WIRE_AUTO_INIT", "0")
2291 .args(args)
2292 .status()
2293 .with_context(|| format!("spawning `wire {}`", args.join(" ")))?;
2294 Ok(status)
2295}
2296
2297/// Check whether `session_home`'s `relay.json` already lists `peer_name`
2298/// under `state.peers`. Best-effort — any read/parse error → false.
2299pub(super) fn session_has_peer(session_home: &std::path::Path, peer_name: &str) -> bool {
2300 val_session_relay_state(session_home)
2301 .and_then(|v| v.get("peers").cloned())
2302 .and_then(|p| p.get(peer_name).cloned())
2303 .is_some()
2304}
2305
2306/// Read a session's `relay.json` directly without mutating the process'
2307/// WIRE_HOME env (which would race other threads / processes). Returns
2308/// `None` on any read or parse error — callers treat missing state as
2309/// "no peers / no endpoints" rather than aborting.
2310fn val_session_relay_state(session_home: &std::path::Path) -> Option<Value> {
2311 let path = session_home.join("config").join("wire").join("relay.json");
2312 let bytes = std::fs::read(&path).ok()?;
2313 serde_json::from_slice(&bytes).ok()
2314}
2315
2316/// v0.6.2 (issue #18): produce a live view of the sister-session mesh.
2317/// One probe per directed edge against the relay backing that edge's
2318/// priority-1 endpoint; output groups by undirected pair.
2319fn cmd_session_mesh_status(stale_secs: u64, as_json: bool) -> Result<()> {
2320 use std::collections::BTreeMap;
2321
2322 // Flatten by session NAME — same dedup logic as pair-all-local so a
2323 // session advertising two local endpoints doesn't get double-counted.
2324 let listing = crate::session::list_local_sessions()?;
2325 let mut by_name: BTreeMap<String, crate::session::LocalSessionView> = BTreeMap::new();
2326 for group in listing.local.into_values() {
2327 for s in group {
2328 by_name.entry(s.name.clone()).or_insert(s);
2329 }
2330 }
2331 let sessions: Vec<crate::session::LocalSessionView> = by_name.into_values().collect();
2332 let federation_only = listing.federation_only;
2333
2334 if sessions.is_empty() {
2335 let msg = "no sister sessions with a local endpoint on this machine.".to_string();
2336 if as_json {
2337 println!(
2338 "{}",
2339 serde_json::to_string(&json!({
2340 "sessions": [],
2341 "edges": [],
2342 "local_relay": null,
2343 "federation_only": federation_only.iter().map(|f| &f.name).collect::<Vec<_>>(),
2344 "summary": {
2345 "session_count": 0,
2346 "edge_count": 0,
2347 "healthy": 0,
2348 "stale": 0,
2349 "asymmetric": 0,
2350 },
2351 "note": msg,
2352 }))?
2353 );
2354 } else {
2355 println!("{msg}");
2356 println!("Use `wire session new --with-local` to create one.");
2357 }
2358 return Ok(());
2359 }
2360
2361 // Build a name → session-state map: relay_state + reachable handle set.
2362 struct SessionState {
2363 view: crate::session::LocalSessionView,
2364 relay_state: Value,
2365 local_relay_url: Option<String>,
2366 }
2367 let mut sstates: Vec<SessionState> = Vec::with_capacity(sessions.len());
2368 for s in sessions {
2369 let relay_state = val_session_relay_state(&s.home_dir)
2370 .unwrap_or_else(|| json!({"self": Value::Null, "peers": {}}));
2371 let local_relay_url = s.local_endpoints.first().map(|e| e.relay_url.clone());
2372 sstates.push(SessionState {
2373 view: s,
2374 relay_state,
2375 local_relay_url,
2376 });
2377 }
2378
2379 // Probe each unique local-relay URL once for healthz so the operator
2380 // sees one liveness line per local relay, not one per edge.
2381 let mut local_relays: BTreeMap<String, bool> = BTreeMap::new();
2382 for s in &sstates {
2383 if let Some(url) = &s.local_relay_url
2384 && !local_relays.contains_key(url)
2385 {
2386 let healthy = probe_relay_healthz(url);
2387 local_relays.insert(url.clone(), healthy);
2388 }
2389 }
2390
2391 let now = std::time::SystemTime::now()
2392 .duration_since(std::time::UNIX_EPOCH)
2393 .map(|d| d.as_secs())
2394 .unwrap_or(0);
2395
2396 // Edges: walk every unordered pair, surface bilateral state + each
2397 // direction's last_pull. Probe priority-1 endpoint (local preferred
2398 // by `peer_endpoints_in_priority_order`).
2399 let mut edges: Vec<Value> = Vec::new();
2400 let mut healthy_count = 0u32;
2401 let mut stale_count = 0u32;
2402 let mut asymmetric_count = 0u32;
2403
2404 for i in 0..sstates.len() {
2405 for j in (i + 1)..sstates.len() {
2406 let a = &sstates[i];
2407 let b = &sstates[j];
2408 // v0.11: relay-state.peers is keyed by the peer's CARD HANDLE
2409 // (DID-derived character), not the session name. Look the
2410 // peer up by its handle (with a session-name fallback for
2411 // pre-v0.11 sessions that haven't re-init'd yet).
2412 let b_key = b.view.handle.as_deref().unwrap_or(b.view.name.as_str());
2413 let a_key = a.view.handle.as_deref().unwrap_or(a.view.name.as_str());
2414 let a_to_b = probe_directed_edge(&a.relay_state, b_key, now);
2415 let b_to_a = probe_directed_edge(&b.relay_state, a_key, now);
2416
2417 let bilateral = a_to_b.pinned && b_to_a.pinned;
2418 // Scope = the most-local scope available in either direction.
2419 // (If a→b is local and b→a is federation, the asymmetric
2420 // detail surfaces below; the headline scope is the better.)
2421 let scope = match (a_to_b.scope.as_deref(), b_to_a.scope.as_deref()) {
2422 (Some("local"), _) | (_, Some("local")) => "local",
2423 (Some("federation"), _) | (_, Some("federation")) => "federation",
2424 _ => "unknown",
2425 };
2426
2427 // Health: stale if either direction's last_pull is older than
2428 // `stale_secs`, or never observed when both sides are pinned.
2429 let mut status = if bilateral { "healthy" } else { "asymmetric" };
2430 if bilateral {
2431 let either_stale = [&a_to_b, &b_to_a].iter().any(|d| match d.silent_secs {
2432 Some(s) => s > stale_secs,
2433 None => d.probed,
2434 });
2435 if either_stale {
2436 status = "stale";
2437 }
2438 }
2439
2440 match status {
2441 "healthy" => healthy_count += 1,
2442 "stale" => stale_count += 1,
2443 "asymmetric" => asymmetric_count += 1,
2444 _ => {}
2445 }
2446
2447 edges.push(json!({
2448 "from": a.view.name,
2449 "to": b.view.name,
2450 "bilateral": bilateral,
2451 "scope": scope,
2452 "status": status,
2453 "directions": {
2454 a.view.name.clone(): direction_summary(&a_to_b),
2455 b.view.name.clone(): direction_summary(&b_to_a),
2456 },
2457 }));
2458 }
2459 }
2460
2461 let summary = json!({
2462 "sessions": sstates.iter().map(|s| json!({
2463 "name": s.view.name,
2464 "handle": s.view.handle,
2465 "cwd": s.view.cwd,
2466 "daemon_running": s.view.daemon_running,
2467 "local_relay": s.local_relay_url,
2468 })).collect::<Vec<_>>(),
2469 "edges": edges,
2470 "local_relays": local_relays.iter().map(|(url, healthy)| json!({
2471 "url": url,
2472 "healthy": healthy,
2473 })).collect::<Vec<_>>(),
2474 "federation_only": federation_only.iter().map(|f| &f.name).collect::<Vec<_>>(),
2475 "summary": {
2476 "session_count": sstates.len(),
2477 "edge_count": edges.len(),
2478 "healthy": healthy_count,
2479 "stale": stale_count,
2480 "asymmetric": asymmetric_count,
2481 "stale_threshold_secs": stale_secs,
2482 },
2483 });
2484
2485 if as_json {
2486 println!("{}", serde_json::to_string(&summary)?);
2487 return Ok(());
2488 }
2489
2490 println!(
2491 "wire mesh: {} session(s), {} edge(s)",
2492 sstates.len(),
2493 edges.len()
2494 );
2495 for (url, healthy) in &local_relays {
2496 let tick = if *healthy { "✓" } else { "✗" };
2497 println!(" local-relay {url} {tick}");
2498 }
2499 if !federation_only.is_empty() {
2500 print!(" federation-only sessions:");
2501 for f in &federation_only {
2502 print!(" {}", f.name);
2503 }
2504 println!();
2505 }
2506
2507 // Pin matrix: sessions × sessions, cell = scope code or "self" / "—".
2508 let names: Vec<&str> = sstates.iter().map(|s| s.view.name.as_str()).collect();
2509 let col_w = names.iter().map(|n| n.len()).max().unwrap_or(8).max(7) + 1;
2510 print!("\n{:>col_w$}", "", col_w = col_w);
2511 for n in &names {
2512 print!("{n:>col_w$}");
2513 }
2514 println!();
2515 for (i, row) in names.iter().enumerate() {
2516 print!("{row:>col_w$}");
2517 for (j, col) in names.iter().enumerate() {
2518 let cell = if i == j {
2519 "self".to_string()
2520 } else {
2521 let d = probe_directed_edge(&sstates[i].relay_state, col, now);
2522 match d.scope.as_deref() {
2523 Some("local") => "local".to_string(),
2524 Some("federation") => "fed".to_string(),
2525 _ => "—".to_string(),
2526 }
2527 };
2528 print!("{cell:>col_w$}");
2529 }
2530 println!();
2531 }
2532
2533 println!("\nHealth (stale threshold: {stale_secs}s):");
2534 for e in &edges {
2535 let from = e["from"].as_str().unwrap_or("?");
2536 let to = e["to"].as_str().unwrap_or("?");
2537 let scope = e["scope"].as_str().unwrap_or("?");
2538 let status = e["status"].as_str().unwrap_or("?");
2539 let mark = match status {
2540 "healthy" => "✓",
2541 "stale" => "⚠",
2542 "asymmetric" => "!",
2543 _ => "?",
2544 };
2545 let dirs = e["directions"].as_object().cloned().unwrap_or_default();
2546 let mut details: Vec<String> = Vec::new();
2547 for (who, d) in &dirs {
2548 let silent = d.get("silent_secs").and_then(Value::as_u64);
2549 let pinned = d.get("pinned").and_then(Value::as_bool).unwrap_or(false);
2550 let probed = d.get("probed").and_then(Value::as_bool).unwrap_or(false);
2551 let label = match (pinned, probed, silent) {
2552 (false, _, _) => format!("{who} has not pinned"),
2553 (true, false, _) => format!("{who} pinned but no endpoint to probe"),
2554 (true, true, Some(s)) if s <= stale_secs => format!("{who} fresh ({s}s)"),
2555 (true, true, Some(s)) => format!("{who} silent {s}s"),
2556 (true, true, None) => format!("{who} never pulled"),
2557 };
2558 details.push(label);
2559 }
2560 println!(
2561 " {mark} {from} ↔ {to} scope={scope} {status:>10} [{}]",
2562 details.join(" | ")
2563 );
2564 }
2565 Ok(())
2566}
2567
2568#[derive(Default)]
2569struct DirectedEdge {
2570 pinned: bool,
2571 scope: Option<String>,
2572 last_pull_at_unix: Option<u64>,
2573 silent_secs: Option<u64>,
2574 probed: bool,
2575 event_count: usize,
2576}
2577
2578/// Probe a single directed edge from `from_state`'s view of `to_name`.
2579/// Picks the priority-1 endpoint (local preferred when reachable) and
2580/// asks the relay for that slot's `last_pull_at_unix`. Silent on probe
2581/// failure (the function records `probed = true`, `last_pull = None`,
2582/// which the caller treats as "never pulled, route exists" = stale).
2583fn probe_directed_edge(from_state: &Value, to_name: &str, now: u64) -> DirectedEdge {
2584 let pinned = from_state
2585 .get("peers")
2586 .and_then(|p| p.get(to_name))
2587 .is_some();
2588 if !pinned {
2589 return DirectedEdge::default();
2590 }
2591 let endpoints = crate::endpoints::peer_endpoints_in_priority_order(from_state, to_name);
2592 let ep = match endpoints.into_iter().next() {
2593 Some(e) => e,
2594 None => {
2595 return DirectedEdge {
2596 pinned: true,
2597 ..Default::default()
2598 };
2599 }
2600 };
2601 let scope = Some(
2602 match ep.scope {
2603 crate::endpoints::EndpointScope::Local => "local",
2604 crate::endpoints::EndpointScope::Lan => "lan",
2605 crate::endpoints::EndpointScope::Uds => "uds",
2606 crate::endpoints::EndpointScope::Federation => "federation",
2607 }
2608 .to_string(),
2609 );
2610 let client = crate::relay_client::RelayClient::new(&ep.relay_url);
2611 let (count, last) = client
2612 .slot_state(&ep.slot_id, &ep.slot_token)
2613 .unwrap_or((0, None));
2614 let silent = last.map(|t| now.saturating_sub(t));
2615 DirectedEdge {
2616 pinned: true,
2617 scope,
2618 last_pull_at_unix: last,
2619 silent_secs: silent,
2620 probed: true,
2621 event_count: count,
2622 }
2623}
2624
2625fn direction_summary(d: &DirectedEdge) -> Value {
2626 json!({
2627 "pinned": d.pinned,
2628 "scope": d.scope,
2629 "probed": d.probed,
2630 "last_pull_at_unix": d.last_pull_at_unix,
2631 "silent_secs": d.silent_secs,
2632 "event_count": d.event_count,
2633 })
2634}
2635
2636/// Best-effort GET `<url>/healthz`. Returns true iff status 2xx.
2637fn probe_relay_healthz(url: &str) -> bool {
2638 let probe_url = format!("{}/healthz", url.trim_end_matches('/'));
2639 let client = match reqwest::blocking::Client::builder()
2640 .timeout(std::time::Duration::from_millis(500))
2641 .build()
2642 {
2643 Ok(c) => c,
2644 Err(_) => return false,
2645 };
2646 match client.get(&probe_url).send() {
2647 Ok(r) => r.status().is_success(),
2648 Err(_) => false,
2649 }
2650}
2651
2652/// v0.9.1: should this command emit JSON by default?
2653///
2654/// - `explicit=true` → operator passed `--json`, always JSON.
2655/// - non-interactive stdout (pipe, capture, agent shell) → JSON, so
2656/// captured output parses cleanly without operators remembering to
2657/// append `--json`. Mirrors `gh`, `kubectl`, etc.
2658/// - interactive TTY → human format (false).
2659/// - `WIRE_NO_AUTO_JSON=1` opts out (back-compat for v0.9 scripts
2660/// that parsed the human text by accident).
2661fn json_default(explicit: bool) -> bool {
2662 if explicit {
2663 return true;
2664 }
2665 if std::env::var("WIRE_NO_AUTO_JSON").is_ok() {
2666 return false;
2667 }
2668 use std::io::IsTerminal;
2669 !std::io::stdout().is_terminal()
2670}
2671
2672pub(super) fn process_alive_pid(pid: u32) -> bool {
2673 // v0.7.3: delegate to the cross-platform helper. See
2674 // `platform::process_alive` for the per-OS dispatch — Windows now
2675 // uses `tasklist /FI "PID eq <n>"` instead of `kill -0`, which
2676 // gave a hard-coded false on Windows pre-v0.7.3.
2677 crate::platform::process_alive(pid)
2678}
2679
2680// ---------- v0.9.2 string-distance + helpful-miss helpers ----------
2681
2682/// Iterative Levenshtein distance between two strings, case-insensitive.
2683/// O(m*n) time, O(min(m, n)) space — fine for the short names wire
2684/// resolves against (typically <30 chars).
2685fn levenshtein_ci(a: &str, b: &str) -> usize {
2686 let a: Vec<char> = a.to_ascii_lowercase().chars().collect();
2687 let b: Vec<char> = b.to_ascii_lowercase().chars().collect();
2688 let (a, b) = if a.len() < b.len() { (a, b) } else { (b, a) };
2689 let (m, n) = (a.len(), b.len());
2690 if m == 0 {
2691 return n;
2692 }
2693 let mut prev: Vec<usize> = (0..=m).collect();
2694 let mut curr = vec![0usize; m + 1];
2695 for j in 1..=n {
2696 curr[0] = j;
2697 for i in 1..=m {
2698 let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
2699 curr[i] = std::cmp::min(
2700 std::cmp::min(curr[i - 1] + 1, prev[i] + 1),
2701 prev[i - 1] + cost,
2702 );
2703 }
2704 std::mem::swap(&mut prev, &mut curr);
2705 }
2706 prev[m]
2707}
2708
2709/// Return up to `max_results` names from `pool` whose edit distance to
2710/// `needle` is ≤ `max_distance`, sorted by distance ascending. Used for
2711/// "did you mean" suggestions on resolution miss.
2712pub fn closest_candidates(
2713 needle: &str,
2714 pool: &[String],
2715 max_distance: usize,
2716 max_results: usize,
2717) -> Vec<String> {
2718 let mut scored: Vec<(usize, &String)> = pool
2719 .iter()
2720 .map(|c| (levenshtein_ci(needle, c), c))
2721 .filter(|(d, _)| *d <= max_distance)
2722 .collect();
2723 scored.sort_by_key(|(d, _)| *d);
2724 scored
2725 .into_iter()
2726 .take(max_results)
2727 .map(|(_, c)| c.clone())
2728 .collect()
2729}
2730
2731/// Extract just the host portion from `https://host:port/path` → `host`.
2732/// Returns empty string if the URL is malformed.
2733pub(super) fn host_of_url(url: &str) -> String {
2734 let no_scheme = url
2735 .trim_start_matches("https://")
2736 .trim_start_matches("http://");
2737 no_scheme
2738 .split('/')
2739 .next()
2740 .unwrap_or("")
2741 .split(':')
2742 .next()
2743 .unwrap_or("")
2744 .to_string()
2745}
2746
2747/// Collect every name that `resolve_name_to_target` would currently
2748/// match: pinned-peer handles, pinned-peer character nicknames, sister
2749/// session names, sister character nicknames, sister handles. Used for
2750/// the `did_you_mean` pool on resolution miss.
2751pub(super) fn known_local_names() -> Vec<String> {
2752 let mut names: Vec<String> = Vec::new();
2753 if let Ok(trust) = config::read_trust() {
2754 // (debug eprintln removed; left bug-trail in commit message)
2755 // trust.agents is an object keyed by handle, NOT an array —
2756 // shape is `{handle: {did, public_keys, tier}, ...}`. Iterate
2757 // the object's keys (which ARE the handles) plus each entry's
2758 // did for the DID-derived character nickname.
2759 if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
2760 for (handle, agent) in agents {
2761 names.push(handle.clone());
2762 if let Some(did) = agent.get("did").and_then(Value::as_str) {
2763 let ch = crate::character::Character::from_did(did);
2764 names.push(ch.nickname);
2765 }
2766 }
2767 }
2768 }
2769 if let Ok(sessions) = crate::session::list_sessions() {
2770 for s in sessions {
2771 names.push(s.name.clone());
2772 if let Some(h) = &s.handle {
2773 names.push(h.clone());
2774 }
2775 if let Some(ch) = &s.character {
2776 names.push(ch.nickname.clone());
2777 }
2778 }
2779 }
2780 names.sort();
2781 names.dedup();
2782 names
2783}
2784
2785#[cfg(test)]
2786mod scan_jsonl_dir_tests {
2787 use super::*;
2788
2789 #[test]
2790 fn scan_jsonl_dir_excludes_pushed_audit_files() {
2791 // Pre-fix `wire status` reported `outbox.events` as the sum of
2792 // both the live outbox files AND the audit-only `*.pushed.jsonl`
2793 // lifecycle logs. On a long-running operator's box that turned
2794 // "11 events queued" into "71811 events queued" — confusing
2795 // and load-bearing-wrong for the silent-send detection class.
2796 let dir = tempfile::tempdir().unwrap();
2797 // Live outbox: one peer, 2 events.
2798 std::fs::write(
2799 dir.path().join("alice.jsonl"),
2800 "{\"event_id\":\"a\"}\n{\"event_id\":\"b\"}\n",
2801 )
2802 .unwrap();
2803 // Audit log: one peer, 100 events. Must NOT count.
2804 let many: String = (0..100)
2805 .map(|i| format!("{{\"event_id\":\"x{i}\",\"ts\":\"...\"}}\n"))
2806 .collect();
2807 std::fs::write(dir.path().join("alice.pushed.jsonl"), many).unwrap();
2808 let result = scan_jsonl_dir(dir.path()).unwrap();
2809 assert_eq!(
2810 result["events"], 2,
2811 "events count must include only live outbox lines, not pushed-log audit lines"
2812 );
2813 assert_eq!(
2814 result["files"], 1,
2815 "files count must reflect 1 live outbox file (the .pushed.jsonl audit log doesn't count as a queued-events surface)"
2816 );
2817 }
2818
2819 #[test]
2820 fn scan_jsonl_dir_zero_when_only_pushed_log_present() {
2821 // Edge case: a peer who's drained their queue still has an
2822 // append-only `<peer>.pushed.jsonl` file but no `<peer>.jsonl`.
2823 // Should report zero events, zero files — there's no pending
2824 // outbox work.
2825 let dir = tempfile::tempdir().unwrap();
2826 std::fs::write(
2827 dir.path().join("alice.pushed.jsonl"),
2828 "{\"event_id\":\"a\"}\n",
2829 )
2830 .unwrap();
2831 let result = scan_jsonl_dir(dir.path()).unwrap();
2832 assert_eq!(result["events"], 0);
2833 assert_eq!(result["files"], 0);
2834 }
2835
2836 #[test]
2837 fn scan_jsonl_dir_returns_zero_for_missing_dir() {
2838 let result = scan_jsonl_dir(std::path::Path::new("/nonexistent")).unwrap();
2839 assert_eq!(result["events"], 0);
2840 assert_eq!(result["files"], 0);
2841 }
2842}