Skip to main content

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