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