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