1	//! CLI surface + dispatch for the `spt` binary (REQ-MSG-2).
2	//!
3	//! Stable arg shapes and exit codes are the contract here. Each subcommand is a
4	//! thin shim: read I/O, call the matching `spt-msg` entry point, render the
5	//! structured outcome, return an exit code. No messaging logic lives in this
6	//! layer.
7	//!
8	//! Exit codes: `0` success (delivered, queued, replied, listed) · `1` runtime
9	//! refusal (no perch, empty body, unidentifiable self) · `2` usage error
10	//! (clap, for unknown/missing args).
11	// [impl->REQ-MSG-2]
12
13	use std::io::{Read, Write};
14	use std::time::Duration;
15
16	use clap::{Parser, Subcommand};
17	use spt_msg::deliver::{self, SendOutcome};
18	use spt_msg::ready::ReadyAgent;
19	use spt_msg::ring::{self, RingOutcome};
20	use spt_net::net::pairing::totp::TotpSeed;
21	use spt_store::perch::{self, ParentHint};
22	use spt_store::registry;
23	use spt_store::subnet::{SubnetError, SubnetStore};
24
25	use crate::elevation::{self, Elevation, Gate};
26	use crate::roster;
27
28	/// Product tag embedded in the `otpauth://` provisioning URI (the authenticator-app
29	/// issuer label). Stable user-facing string.
30	const ISSUER: &str = "spt";
31
32	/// `spt pair show-totp` refused the fetch for lack of OS elevation. Distinct from the
33	/// crate-wide codes (`0` ok · `1` runtime refusal · `2` usage): `3` says the fetch was
34	/// *refused, not failed* — re-run elevated, or read the code from your authenticator app.
35	const EXIT_NOT_ELEVATED: i32 = 3;
36
37	#[derive(Parser)]
38	#[command(name = "spt", version, about = "spt-core local messaging", long_about = None)]
39	struct Cli {
40	    #[command(subcommand)]
41	    cmd: Cmd,
42	}
43
44	#[derive(Subcommand)]
45	enum Cmd {
46	    /// Send a message (body read from stdin). Fire-and-forget.
47	    Send {
48	        /// Target perch id. May be omitted when --reply-to is given.
49	        target: Option<String>,
50	        /// Sender id written into __REPLY_TO__ (auto-detected from session if omitted).
51	        #[arg(long)]
52	        from: Option<String>,
53	        /// Reply to this sender; sets the target and labels output REPLIED.
54	        #[arg(long)]
55	        reply_to: Option<String>,
56	        /// Spool only (deferred / hook channel) — no live wake.
57	        #[arg(long)]
58	        deferred: bool,
59	    },
60	    /// Send and block for a reply (body read from stdin; reply printed to stdout).
61	    Ring {
62	        /// Target perch id.
63	        target: String,
64	        /// Sender id (auto-detected from session if omitted).
65	        #[arg(long)]
66	        from: Option<String>,
67	        /// Seconds to wait for a reply before giving up.
68	        #[arg(long, default_value = "60")]
69	        timeout: u64,
70	    },
71	    /// Become reachable: register the perch and listen for messages (blocks).
72	    Ready {
73	        /// This agent's perch id.
74	        id: String,
75	    },
76	    /// Receive messages: drain backlog then listen, printing each to stdout.
77	    Poll {
78	        /// This agent's perch id.
79	        id: String,
80	        /// Run a single drain+receive cycle, then exit (legacy one-shot).
81	        #[arg(long)]
82	        once: bool,
83	    },
84	    /// List perches under the owlery (id, state, address, ready, alive).
85	    List,
86	    /// Soft-stop a perch: remove the ready marker + unregister (spool preserved).
87	    Stop {
88	        /// Perch id to stop.
89	        id: String,
90	    },
91	    /// Print this session's own perch id (from $OWL_SESSION_ID / $SPT_AGENT_ID).
92	    Whoami,
93	    /// Subnet pairing operations (REQ-PAIR-*). Local-only here — the cross-node
94	    /// ceremony rides spt-net (M4 D4+).
95	    Pair {
96	        #[command(subcommand)]
97	        action: PairCmd,
98	    },
99	    /// Show a daemon-hosted session's live activity buffer (PTY digest) — the
100	    /// at-a-glance "what is this agent doing now" view (ADR-0008). Pulls a
101	    /// snapshot, or `--follow`s the delta-stream. Local addressing only (the
102	    /// `[subnet:]id@node` cross-node form is M4).
103	    Digest {
104	        /// The (local) endpoint id to read.
105	        id: String,
106	        /// Stream live changes instead of a one-shot snapshot (Ctrl-C to stop).
107	        #[arg(long)]
108	        follow: bool,
109	        /// Emit structured JSON instead of the human-glanceable render.
110	        #[arg(long)]
111	        json: bool,
112	    },
113	    /// Rename an endpoint's logical id, rippled on-disk (REQ-INST-11 / ADR-0006
114	    /// §2): the Self perch dir, its nested psyche/worker perches, and every
115	    /// `info.json`. Refuses while the perch is live (stop it first). The subnet
116	    /// registry rows ripple when the daemon-held registry lands (D4); the
117	    /// `a-<id>` context branch with the context store (D6).
118	    Rename {
119	        /// The endpoint's current (bare) id.
120	        old_id: String,
121	        /// The new (bare) id — charset-validated; `:`/`@` are reserved.
122	        new_id: String,
123	    },
124	    /// Endpoint access whitelist (ADR-0009, REQ-SEC-1): which origin nodes may
125	    /// send an endpoint unsolicited off-node inbound. Absent entry = open;
126	    /// `allow` flips the endpoint to restricted; revoking the last node leaves
127	    /// it locked down; `open` deletes the restriction.
128	    Access {
129	        #[command(subcommand)]
130	        action: AccessCmd,
131	    },
132	    /// Issue a subnet-wide user notification (ADR-0007, REQ-NOTIF-2): produced
133	    /// into the replicated notif spool and first-fired at the user's
134	    /// most-recently-active endpoint in that subnet. Body from the trailing
135	    /// arg, or stdin when omitted.
136	    Notify {
137	        /// Notification body (read from stdin when omitted).
138	        body: Option<String>,
139	        /// Which subnet to notify. Required only when the node holds several
140	        /// (no guessing — the `pair show-totp` stance).
141	        #[arg(long)]
142	        subnet: Option<String>,
143	        /// Issuer endpoint id (auto-detected from session if omitted).
144	        #[arg(long)]
145	        from: Option<String>,
146	    },
147	    /// Inspect and acknowledge notifications (ADR-0007). Dismissal is the
148	    /// explicit ack — it latches and replicates subnet-wide.
149	    Notif {
150	        #[command(subcommand)]
151	        action: NotifCmd,
152	    },
153	    /// Harness-contract inbound surface (REQ-API-1): the entry points a
154	    /// harness's hooks fire to mutate on-disk SPT state.
155	    Api(crate::api::ApiArgs),
156	    /// Run the per-machine broker/brain daemon (REQ-DAEMON-3). Internal: an
157	    /// `api` invocation auto-starts this; operators rarely run it directly.
158	    #[command(hide = true)]
159	    Daemon,
160	}
161
162	/// `spt access <action>` subcommands (REQ-SEC-1).
163	#[derive(Subcommand)]
164	enum AccessCmd {
165	    /// Whitelist a node for an endpoint (creates the restriction if absent).
166	    Allow {
167	        /// The endpoint to restrict.
168	        endpoint: String,
169	        /// The origin node's pubkey hex (as shown by `spt list` / pairing).
170	        node: String,
171	    },
172	    /// Remove a node from an endpoint's whitelist. Never widens: revoking the
173	    /// last node leaves the endpoint locked down (all unsolicited refused).
174	    Revoke {
175	        endpoint: String,
176	        node: String,
177	    },
178	    /// Delete an endpoint's restriction entirely — back to default-open.
179	    Open {
180	        endpoint: String,
181	    },
182	    /// List restrictions (all endpoints, or one).
183	    List {
184	        endpoint: Option<String>,
185	    },
186	}
187
188	/// `spt notif <action>` subcommands (ADR-0007).
189	#[derive(Subcommand)]
190	enum NotifCmd {
191	    /// List notifications (all member subnets, or one).
192	    List {
193	        /// Limit to one subnet.
194	        #[arg(long)]
195	        subnet: Option<String>,
196	    },
197	    /// Dismiss (ack) a notification by id — latches, replicates subnet-wide.
198	    Dismiss {
199	        /// The notif id (as shown by `spt notif list`).
200	        notif_id: String,
201	    },
202	}
203
204	/// `spt pair <action>` subcommands.
205	#[derive(Subcommand)]
206	enum PairCmd {
207	    /// Show a subnet's current 6-digit pairing code + `otpauth://` URI — the
208	    /// node-bound fetch, gated behind OS elevation (REQ-PAIR-3/6). With no flag the
209	    /// node's sole subnet is used; if it holds several, `--subnet <name>` is required
210	    /// (no guessing). `--create-new <name>` mints a fresh subnet first.
211	    ShowTotp {
212	        /// Which subnet's code to show. Required only when the node holds several.
213	        #[arg(long)]
214	        subnet: Option<String>,
215	        /// Mint a fresh subnet with this name (this node becomes the sole seed-holder),
216	        /// then show its code. Mutually exclusive with `--subnet`.
217	        #[arg(long, value_name = "NAME", conflicts_with = "subnet")]
218	        create_new: Option<String>,
219	    },
220	}
221
222	/// Parse args and dispatch. Returns the process exit code.
223	pub fn run() -> i32 {
224	    let cli = Cli::parse();
225	    match cli.cmd {
226	        Cmd::Send { target, from, reply_to, deferred } => cmd_send(target, from, reply_to, deferred),
227	        Cmd::Ring { target, from, timeout } => cmd_ring(target, from, timeout),
228	        Cmd::Ready { id } => run_listen(&id, false),
229	        Cmd::Poll { id, once } => run_listen(&id, once),
230	        Cmd::List => cmd_list(),
231	        Cmd::Stop { id } => cmd_stop(&id),
232	        Cmd::Whoami => cmd_whoami(),
233	        Cmd::Pair { action } => match action {
234	            PairCmd::ShowTotp { subnet, create_new } => cmd_pair_show_totp(subnet, create_new),
235	        },
236	        Cmd::Access { action } => cmd_access(action),
237	        Cmd::Notify { body, subnet, from } => cmd_notify(body, subnet, from),
238	        Cmd::Notif { action } => match action {
239	            NotifCmd::List { subnet } => cmd_notif_list(subnet),
240	            NotifCmd::Dismiss { notif_id } => cmd_notif_dismiss(&notif_id),
241	        },
242	        Cmd::Digest { id, follow, json } => cmd_digest(&id, follow, json),
243	        Cmd::Rename { old_id, new_id } => cmd_rename(&old_id, &new_id),
244	        Cmd::Api(args) => crate::api::run(args),
245	        Cmd::Daemon => cmd_daemon(),
246	    }
247	}
248
249	/// `spt digest <id>` — show a daemon-hosted session's live activity buffer
250	/// (REQ-TERM-4 / ADR-0008). Ensures the daemon is up, then pulls a snapshot or
251	/// follows the delta-stream over the digest-control channel. Local addressing
252	/// only: a qualified `[subnet:]id@node` form is refused (cross-node is M4).
253	// [impl->REQ-TERM-4]
254	fn cmd_digest(id: &str, follow: bool, json: bool) -> i32 {
255	    // Local addressing only — the qualified cross-node form is M4 (spt-net).
256	    if id.contains('@') || id.contains(':') {
257	        eprintln!("CROSS_NODE_M4:{id} — qualified [subnet:]id@node addressing is M4, not yet supported");
258	        return 1;
259	    }
260	    // The digest lives in the daemon; bring it up if absent (best-effort).
261	    if let Err(e) = spt_daemon::ensure_running() {
262	        eprintln!("DAEMON_START_WARN: {e}");
263	    }
264	    let name = spt_daemon::digest_socket_name();
265
266	    if follow {
267	        let res = spt_daemon::follow(&name, id, 0, |up| {
268	            if json {
269	                println!("{}", spt_daemon::update_to_json(up));
270	            } else {
271	                print!("{}", spt_daemon::render_update(up));
272	            }
273	            let _ = std::io::stdout().flush();
274	            true // stream until the connection ends / the user interrupts
275	        });
276	        return match res {
277	            Ok(()) => 0,
278	            Err(e) => {
279	                eprintln!("DIGEST_FOLLOW_FAIL:{id}: {e}");
280	                1
281	            }
282	        };
283	    }
284
285	    match spt_daemon::pull_snapshot(&name, id) {
286	        Ok(Some((digest, version))) => {
287	            if json {
288	                println!("{}", spt_daemon::digest_to_json(&digest));
289	            } else {
290	                print!("{}", spt_daemon::render_digest(&digest));
291	                let _ = std::io::stdout().flush();
292	            }
293	            eprintln!("DIGEST:{id} version={version}");
294	            0
295	        }
296	        Ok(None) => {
297	            eprintln!("NO_DIGEST:{id} has no live activity buffer (not a daemon-hosted session?)");
298	            1
299	        }
300	        Err(e) => {
301	            eprintln!("DIGEST_FAIL:{id}: {e}");
302	            1
303	        }
304	    }
305	}
306
307	/// Run the per-machine daemon (the hidden `spt daemon` entry point). Blocks
308	/// serving the broker + seed-control channels until killed; a bind contention
309	/// (another daemon already owns this home) surfaces as a non-zero exit.
310	// [impl->REQ-DAEMON-3]
311	fn cmd_daemon() -> i32 {
312	    match spt_daemon::Daemon::run() {
313	        Ok(()) => 0,
314	        Err(e) => {
315	            eprintln!("DAEMON_EXIT: {e}");
316	            1
317	        }
318	    }
319	}
320
321	/// Read stdin to a string (message body source for send/ring).
322	fn read_stdin() -> String {
323	    let mut body = String::new();
324	    let _ = std::io::stdin().read_to_string(&mut body);
325	    body
326	}
327
328	/// Resolve which subnet a notif targets: an explicit `--subnet` must be a
329	/// member; with no flag the node's sole subnet is used; several ⇒ refuse and
330	/// force qualification (no guessing — the D3c/`show-totp` stance).
331	// [impl->REQ-NOTIF-2]
332	fn resolve_notif_subnet(store: &SubnetStore, flag: Option<String>) -> Result<String, i32> {
333	    match flag {
334	        Some(name) => {
335	            if store.find(&name).is_some() {
336	                Ok(name)
337	            } else {
338	                eprintln!("NO_SUBNET:{name} — this node is not a member");
339	                Err(1)
340	            }
341	        }
342	        None => match store.subnets.len() {
343	            0 => {
344	                eprintln!("NO_SUBNET: this node holds no subnet — pair first");
345	                Err(1)
346	            }
347	            1 => Ok(store.subnets[0].name.clone()),
348	            _ => {
349	                eprintln!(
350	                    "AMBIGUOUS_SUBNET: this node holds several — pass --subnet <name> (no guessing)"
351	                );
352	                Err(1)
353	            }
354	        },
355	    }
356	}
357
358	/// `spt notify` — agent-issued subnet notif (ADR-0007, REQ-NOTIF-2): produce
359	/// into the replicated spool (id minted from this node's EpochSource), then
360	/// first-fire at the user's most-recently-active visible endpoint. The
361	/// envelope `from` carries issuer endpoint **and node** (`<id>@<node-hex>`);
362	/// the subnet rides the envelope's own attribute.
363	// [impl->REQ-NOTIF-2]
364	fn cmd_notify(body: Option<String>, subnet: Option<String>, from: Option<String>) -> i32 {
365	    let issuer = from.or_else(roster::detect_self_id).unwrap_or_default();
366	    if issuer.is_empty() {
367	        eprintln!("NO_SELF: notify needs an issuer (--from <id> or $OWL_SESSION_ID)");
368	        return 1;
369	    }
370	    let subnets = SubnetStore::load();
371	    let subnet = match resolve_notif_subnet(&subnets, subnet) {
372	        Ok(s) => s,
373	        Err(code) => return code,
374	    };
375	    let body = body.unwrap_or_else(read_stdin);
376	    let body = body.trim();
377	    if body.is_empty() {
378	        eprintln!("EMPTY_BODY: nothing to notify");
379	        return 1;
380	    }
381	    let node_hex = match spt_store::nodeid::load_or_create() {
382	        Ok(identity) => identity.public_key().to_hex(),
383	        Err(e) => {
384	            eprintln!("NODE_ID_FAIL: {e}");
385	            return 1;
386	        }
387	    };
388	    let store = match spt_store::notif::NotifStore::open() {
389	        Ok(s) => s,
390	        Err(e) => {
391	            eprintln!("NOTIF_STORE_FAIL: {e}");
392	            return 1;
393	        }
394	    };
395	    let mut epochs = spt_store::epoch::EpochSource::load();
396	    let policy = spt_daemon::NotifSurfacePolicy::load();
397	    let from_id = format!("{issuer}@{node_hex}");
398	    match spt_daemon::produce_and_first_fire(
399	        &store,
400	        &node_hex,
401	        &mut epochs,
402	        &subnet,
403	        spt_daemon::NOTIF_KIND_AGENT,
404	        &from_id,
405	        body,
406	        &policy,
407	        &perch::owlery_dir(),
408	        now_ms(),
409	    ) {
410	        Ok((row, spt_daemon::FirstFireOutcome::Fired { to, .. })) => {
411	            println!("NOTIFIED:{} -> {to}", row.notif_id);
412	            0
413	        }
414	        Ok((row, spt_daemon::FirstFireOutcome::NoTarget)) => {
415	            println!(
416	                "NOTIF_PENDING:{} (no live endpoint in {subnet}; surfaces at the next boundary)",
417	                row.notif_id
418	            );
419	            0
420	        }
421	        Err(e) => {
422	            eprintln!("NOTIFY_FAIL: {e}");
423	            1
424	        }
425	    }
426	}
427
428	/// `spt notif list` — the ack surface's read side.
429	// [impl->REQ-NOTIF-2]
430	fn cmd_notif_list(subnet: Option<String>) -> i32 {
431	    let store = match spt_store::notif::NotifStore::open() {
432	        Ok(s) => s,
433	        Err(e) => {
434	            eprintln!("NOTIF_STORE_FAIL: {e}");
435	            return 1;
436	        }
437	    };
438	    let subnets = SubnetStore::load();
439	    let names: Vec<String> = match &subnet {
440	        Some(one) => vec![one.clone()],
441	        None => subnets.subnets.iter().map(|s| s.name.clone()).collect(),
442	    };
443	    let mut shown = 0usize;
444	    for name in names {
445	        for row in store.list(&name).unwrap_or_default() {
446	            let state = if row.dismissed {
447	                "dismissed".to_string()
448	            } else if row.seen.is_empty() {
449	                "fresh".to_string()
450	            } else {
451	                format!("seen:{}", row.seen.len())
452	            };
453	            // First body line only — the glanceable view.
454	            let head = row.body.lines().next().unwrap_or_default();
455	            println!("{}\t{}\t{}\t{}\t{}", row.notif_id, row.subnet, state, row.from_id, head);
456	            shown += 1;
457	        }
458	    }
459	    eprintln!("NOTIFS:{shown}");
460	    0
461	}
462
463	/// `spt notif dismiss <id>` — the explicit ack: latches locally, replicates
464	/// subnet-wide via the D8-2 feed.
465	// [impl->REQ-NOTIF-2]
466	fn cmd_notif_dismiss(notif_id: &str) -> i32 {
467	    let store = match spt_store::notif::NotifStore::open() {
468	        Ok(s) => s,
469	        Err(e) => {
470	            eprintln!("NOTIF_STORE_FAIL: {e}");
471	            return 1;
472	        }
473	    };
474	    match store.dismiss(notif_id) {
475	        Ok(true) => {
476	            println!("DISMISSED:{notif_id}");
477	            0
478	        }
479	        Ok(false) => {
480	            eprintln!("UNKNOWN_OR_DISMISSED:{notif_id}");
481	            1
482	        }
483	        Err(e) => {
484	            eprintln!("DISMISS_FAIL:{notif_id}: {e}");
485	            1
486	        }
487	    }
488	}
489
490	/// Epoch milliseconds (the notif surfacing clock).
491	fn now_ms() -> u64 {
492	    std::time::SystemTime::now()
493	        .duration_since(std::time::UNIX_EPOCH)
494	        .map(|d| d.as_millis() as u64)
495	        .unwrap_or(0)
496	}
497
498	/// Resolve the sender id: explicit `--from`, else session auto-detect, else "".
499	fn resolve_from(from: Option<String>) -> String {
500	    from.or_else(roster::detect_self_id).unwrap_or_default()
501	}
502
503	fn cmd_send(target: Option<String>, from: Option<String>, reply_to: Option<String>, deferred: bool) -> i32 {
504	    let is_reply = reply_to.is_some();
505	    // --reply-to provides the target when no positional target is given.
506	    let target = match target.or_else(|| reply_to.clone()) {
507	        Some(t) => t,
508	        None => {
509	            eprintln!("USAGE: a target (or --reply-to <sender>) is required");
510	            return 2;
511	        }
512	    };
513	    let from = resolve_from(from);
514	    let body = read_stdin();
515	    let owlery = perch::owlery_dir();
516
517	    let outcome = if deferred {
518	        deliver::send_deferred(&target, &from, &body)
519	    } else {
520	        deliver::send(&target, &from, &body, &owlery)
521	    };
522
523	    match outcome {
524	        SendOutcome::Sent => {
525	            eprintln!("{}:{}", if is_reply { "REPLIED" } else { "SENT" }, target);
526	            0
527	        }
528	        SendOutcome::Queued => {
529	            let label = if deferred {
530	                "DEFERRED"
531	            } else if is_reply {
532	                "REPLIED(QUEUED)"
533	            } else {
534	                "QUEUED"
535	            };
536	            eprintln!("{}:{}", label, target);
537	            0
538	        }
539	        SendOutcome::NoPerch => {
540	            if deferred {
541	                // The wire record carries no deferred class (v1) — a remote
542	                // deferred send is a documented seam, not a silent reclass.
543	                eprintln!("NO_PERCH:{} is not listening (deferred stays local-only)", target);
544	                return 1;
545	            }
546	            // The WAN leg (D9-1-4): resolve against the registry snapshots
547	            // and ship to the instance's node; its daemon delivers/spools.
548	            // [impl->REQ-NET-1]
549	            match crate::wansend::wan_send(&target, &from, &body) {
550	                crate::wansend::WanSendOutcome::Sent { node } => {
551	                    eprintln!(
552	                        "{}:{}@{}",
553	                        if is_reply { "REPLIED(WAN)" } else { "SENT(WAN)" },
554	                        target,
555	                        node
556	                    );
557	                    0
558	                }
559	                crate::wansend::WanSendOutcome::Ambiguous(why) => {
560	                    eprintln!("AMBIGUOUS:{target} — qualify with subnet: or @node ({why})");
561	                    1
562	                }
563	                crate::wansend::WanSendOutcome::NotFound => {
564	                    eprintln!("NO_PERCH:{} is not listening", target);
565	                    1
566	                }
567	                crate::wansend::WanSendOutcome::Failed(e) => {
568	                    eprintln!("WAN_FAIL:{target} — {e}");
569	                    1
570	                }
571	            }
572	        }
573	        SendOutcome::Empty => {
574	            eprintln!("EMPTY_MSG: refusing to send empty message to {}", target);
575	            1
576	        }
577	    }
578	}
579
580	fn cmd_ring(target: String, from: Option<String>, timeout: u64) -> i32 {
581	    let from = resolve_from(from);
582	    if from.is_empty() {
583	        eprintln!("NO_SELF: ring needs a sender id (--from <id> or $OWL_SESSION_ID)");
584	        return 1;
585	    }
586	    let body = read_stdin();
587	    let owlery = perch::owlery_dir();
588
589	    match ring::ring(&target, &from, &body, Duration::from_secs(timeout), &owlery) {
590	        RingOutcome::Replied(reply) => {
591	            print!("{}", reply);
592	            let _ = std::io::stdout().flush();
593	            0
594	        }
595	        RingOutcome::TimedOut => {
596	            eprintln!("TIMEOUT:{} did not reply within {}s", target, timeout);
597	            0
598	        }
599	        RingOutcome::Queued => {
600	            eprintln!("QUEUED:{}", target);
601	            0
602	        }
603	        RingOutcome::Delivered => {
604	            eprintln!("SENT:{} (reply will arrive on your listener)", target);
605	            0
606	        }
607	        RingOutcome::NoPerch => {
608	            eprintln!("NO_PERCH:{} is not listening", target);
609	            1
610	        }
611	        RingOutcome::Empty => {
612	            eprintln!("EMPTY_MSG: refusing to ring {} with an empty message", target);
613	            1
614	        }
615	    }
616	}
617
618	/// Run the listener loop (REQ-MSG-3 lifecycle, driven from the CLI). Blocks
619	/// until the process is killed unless `once` is set. Each received message is
620	/// printed framed to stdout; status goes to stderr.
621	///
622	/// Interim M1 model: the perch is reachable only while this process runs. M3's
623	/// spt-daemon decouples registration from the live process.
624	fn run_listen(id: &str, once: bool) -> i32 {
625	    let (agent, backlog) = match ReadyAgent::start(id) {
626	        Ok(pair) => pair,
627	        Err(e) => {
628	            eprintln!("READY_FAIL:{}: {}", id, e);
629	            return 1;
630	        }
631	    };
632	    eprintln!("READY:{}", id);
633	    for msg in backlog {
634	        emit(&msg);
635	    }
636
637	    if once {
638	        if let Some(msg) = agent.recv() {
639	            emit(&msg);
640	        }
641	        for msg in agent.drain_spool() {
642	            emit(&msg);
643	        }
644	        return 0;
645	    }
646
647	    loop {
648	        if let Some(msg) = agent.recv() {
649	            emit(&msg);
650	        }
651	        for msg in agent.drain_spool() {
652	            emit(&msg);
653	        }
654	    }
655	}
656
657	/// Print a framed message to stdout and flush (one delivery per emit).
658	fn emit(msg: &str) {
659	    print!("{}", msg);
660	    let _ = std::io::stdout().flush();
661	}
662
663	fn cmd_list() -> i32 {
664	    let perches = roster::enumerate();
665	    if perches.is_empty() {
666	        eprintln!("(no perches)");
667	        return 0;
668	    }
669	    for p in perches {
670	        println!(
671	            "{:<28} {:<14} {:<22} ready={} alive={}",
672	            p.id,
673	            p.state,
674	            p.address.as_deref().unwrap_or("-"),
675	            p.ready,
676	            p.alive
677	        );
678	    }
679	    0
680	}
681
682	fn cmd_stop(id: &str) -> i32 {
683	    let owlery = perch::owlery_dir();
684	    // Soft-cleanup (HAZARD-SOFT-CLEANUP 6.2): remove the ready marker +
685	    // unregister the address; spool.db and info.json are preserved.
686	    let removed = std::fs::remove_file(perch::resolve_ready_file(id, ParentHint::Infer)).is_ok();
687	    let _ = registry::unregister_address(id, &owlery);
688	    if removed {
689	        eprintln!("STOPPED:{}", id);
690	    } else {
691	        eprintln!("STOPPED:{} (no ready marker; address unregistered)", id);
692	    }
693	    0
694	}
695
696	fn cmd_whoami() -> i32 {
697	    match roster::detect_self_id() {
698	        Some(id) => {
699	            println!("{}", id);
700	            0
701	        }
702	        None => {
703	            eprintln!("UNKNOWN: no perch matches this session ($OWL_SESSION_ID / $SPT_AGENT_ID)");
704	            1
705	        }
706	    }
707	}
708
709	/// What `show-totp` resolved its arguments to against the node's subnet store — the
710	/// platform-independent half of the command, decided before any OS elevation call so
711	/// it can be unit-tested hermetically.
712	enum Resolved {
713	    /// A subnet to surface: its name + raw seed bytes. `minted` = freshly created by
714	    /// `--create-new` (persist it only once the code is actually shown).
715	    Subnet { name: String, seed: [u8; spt_store::subnet::TOTP_SEED_LEN], minted: bool },
716	    /// Several subnets and no `--subnet` — refuse rather than guess.
717	    Ambiguous(Vec<String>),
718	    /// The node belongs to no subnet at all.
719	    Empty,
720	    /// A named subnet (`--subnet`) is not in the store.
721	    NotFound(String),
722	    /// `--create-new <name>` for a subnet that already exists.
723	    Exists(String),
724	    /// The stored seed is unreadable (corrupt hex/length) — re-pair to re-provision.
725	    Corrupt(String),
726	}
727
728	/// The rendered outcome of `show-totp`: an exit code, the optional stdout block (the
729	/// code + URI — present ONLY on the elevated show path, never leaked otherwise), a
730	/// stderr status line, and whether to persist a freshly-minted subnet. Pure over its
731	/// inputs (no IO, no OS calls) so every branch is unit-tested.
732	struct ShowResult {
733	    code: i32,
734	    out: Option<String>,
735	    msg: String,
736	    save: bool,
737	}
738
739	/// Resolve `show-totp` arguments against the store (mutating it iff `--create-new`
740	/// mints). The mint is in-memory only; the caller persists it just on the show path.
741	// [impl->REQ-PAIR-3]
742	fn resolve(store: &mut SubnetStore, subnet: Option<String>, create_new: Option<String>) -> Resolved {
743	    if let Some(name) = create_new {
744	        return match store.create_subnet(&name) {
745	            Ok(rec) => match rec.seed_bytes() {
746	                Some(seed) => Resolved::Subnet { name: rec.name.clone(), seed, minted: true },
747	                None => Resolved::Corrupt(rec.name.clone()),
748	            },
749	            Err(SubnetError::Exists(n)) => Resolved::Exists(n),
750	            Err(e) => Resolved::Corrupt(e.to_string()),
751	        };
752	    }
753	    if let Some(name) = subnet {
754	        return match store.find(&name) {
755	            Some(rec) => match rec.seed_bytes() {
756	                Some(seed) => Resolved::Subnet { name: rec.name.clone(), seed, minted: false },
757	                None => Resolved::Corrupt(rec.name.clone()),
758	            },
759	            None => Resolved::NotFound(name),
760	        };
761	    }
762	    // No flag: auto-select iff the node holds exactly one subnet (same no-guessing
763	    // ethos as D3c addressing — several is an error, not a default pick).
764	    match store.subnets.len() {
765	        0 => Resolved::Empty,
766	        1 => match store.subnets[0].seed_bytes() {
767	            Some(seed) => Resolved::Subnet { name: store.subnets[0].name.clone(), seed, minted: false },
768	            None => Resolved::Corrupt(store.subnets[0].name.clone()),
769	        },
770	        _ => Resolved::Ambiguous(store.subnets.iter().map(|s| s.name.clone()).collect()),
771	    }
772	}
773
774	/// Decide the rendered outcome from a resolution + the clock + the elevation level.
775	/// The elevation gate is applied LAST: a resolved subnet still falls back (and leaks
776	/// no code) unless the process is confirmed elevated.
777	// [impl->REQ-PAIR-3] [impl->REQ-PAIR-6]
778	fn decide_show_totp(resolved: Resolved, now: u64, elevation: Elevation) -> ShowResult {
779	    match resolved {
780	        Resolved::Subnet { name, seed, minted } => match elevation::gate_for(elevation) {
781	            Gate::Show => {
782	                let totp = TotpSeed::from_bytes(seed);
783	                // The URI carries the base32 secret — importable into an authenticator
784	                // app; QR *rendering* is GUI/later, so we print the URI text only.
785	                let out = format!("{}\n{}\n", totp.code_at(now), totp.otpauth_uri(ISSUER, &name));
786	                ShowResult { code: 0, out: Some(out), msg: format!("TOTP:{name} (elevated)"), save: minted }
787	            }
788	            // Refused fetch: no code, and we do NOT persist a `--create-new` mint —
789	            // a refused command leaves no durable side effect (re-run elevated).
790	            Gate::Fallback => ShowResult {
791	                code: EXIT_NOT_ELEVATED,
792	                out: None,
793	                msg: elevation::fallback_message(&name),
794	                save: false,
795	            },
796	        },
797	        Resolved::Ambiguous(names) => ShowResult {
798	            code: 2,
799	            out: None,
800	            msg: format!(
801	                "AMBIGUOUS_SUBNET: this node holds {} subnets ({}) — pass --subnet <name>",
802	                names.len(),
803	                names.join(", ")
804	            ),
805	            save: false,
806	        },
807	        Resolved::Empty => ShowResult {
808	            code: 2,
809	            out: None,
810	            msg: "NO_SUBNETS: this node belongs to no subnet — pair first, or mint one with --create-new <name>".to_string(),
811	            save: false,
812	        },
813	        Resolved::NotFound(n) => ShowResult {
814	            code: 2,
815	            out: None,
816	            msg: format!("NO_SUCH_SUBNET:{n}"),
817	            save: false,
818	        },
819	        Resolved::Exists(n) => ShowResult {
820	            code: 2,
821	            out: None,
822	            msg: format!("SUBNET_EXISTS:{n} — already a member; drop --create-new to show its code"),
823	            save: false,
824	        },
825	        Resolved::Corrupt(n) => ShowResult {
826	            code: 1,
827	            out: None,
828	            msg: format!("CORRUPT_SEED:{n} — stored seed is unreadable; re-pair to re-provision"),
829	            save: false,
830	        },
831	    }
832	}
833
834	/// `spt pair show-totp` — surface a subnet's current code off this node, behind the
835	/// OS-elevation gate (REQ-PAIR-3/6). Wires the real store, clock, and elevation probe
836	/// into the unit-tested [`resolve`]/[`decide_show_totp`] core.
837	// [impl->REQ-PAIR-3] [impl->REQ-PAIR-6]
838	fn cmd_pair_show_totp(subnet: Option<String>, create_new: Option<String>) -> i32 {
839	    let mut store = SubnetStore::load();
840	    let resolved = resolve(&mut store, subnet, create_new);
841	    let result = decide_show_totp(resolved, now_unix(), elevation::current());
842
843	    if result.save {
844	        if let Err(e) = store.save() {
845	            eprintln!("SAVE_FAIL: freshly minted subnet not persisted: {e}");
846	            return 1;
847	        }
848	    }
849	    if let Some(out) = result.out {
850	        print!("{out}");
851	        let _ = std::io::stdout().flush();
852	    }
853	    eprintln!("{}", result.msg);
854	    result.code
855	}
856
857	/// Current unix time in whole seconds (the TOTP clock input). A pre-epoch clock
858	/// degrades to step 0 rather than panicking.
859	/// `spt rename <old> <new>` — the on-disk rename ripple (REQ-INST-11). Validates
860	/// the new id (the `:`/`@` reserved-delimiter rule — a qualified string can never
861	/// become a bare id), then ripples the perch tree via
862	/// [`spt_store::rename::rename_perch_in`]. Prints each rippled perch id.
863	// [impl->REQ-INST-11]
864	/// `spt access allow|revoke|open|list` — operate the endpoint access whitelist
865	/// (`identity/trust/access.json`, ADR-0009). The daemon's gate re-reads the
866	/// store per check, so edits take effect on the next inbound — no restart.
867	// [impl->REQ-SEC-1]
868	fn cmd_access(action: AccessCmd) -> i32 {
869	    use spt_store::access::AccessStore;
870
871	    /// A node subject must be a 64-char Ed25519 pubkey hex — refuse typos
872	    /// rather than store a subject that can never match (a silent lockout).
873	    fn valid_node_hex(node: &str) -> bool {
874	        node.len() == 64 && node.chars().all(|c| c.is_ascii_hexdigit())
875	    }
876	    fn save(store: &AccessStore) -> i32 {
877	        match store.save() {
878	            Ok(()) => 0,
879	            Err(e) => {
880	                eprintln!("ACCESS_FAIL: {e}");
881	                1
882	            }
883	        }
884	    }
885
886	    match action {
887	        AccessCmd::Allow { endpoint, node } => {
888	            let node = node.to_lowercase();
889	            if !valid_node_hex(&node) {
890	                eprintln!("ACCESS_BAD_NODE: {node:?} is not a 64-hex node pubkey");
891	                return 1;
892	            }
893	            let mut s = AccessStore::load();
894	            s.allow(&endpoint, &node);
895	            let code = save(&s);
896	            if code == 0 {
897	                eprintln!("ACCESS_ALLOW:{endpoint}:{node} (endpoint is now restricted)");
898	            }
899	            code
900	        }
901	        AccessCmd::Revoke { endpoint, node } => {
902	            let node = node.to_lowercase();
903	            let mut s = AccessStore::load();
904	            if !s.revoke(&endpoint, &node) {
905	                eprintln!("ACCESS_NOT_LISTED:{endpoint}:{node} (nothing revoked)");
906	                return 1;
907	            }
908	            let locked = s.lookup(&endpoint).map(|e| e.nodes.is_empty()).unwrap_or(false);
909	            let code = save(&s);
910	            if code == 0 {
911	                eprintln!("ACCESS_REVOKE:{endpoint}:{node}");
912	                if locked {
913	                    eprintln!(
914	                        "ACCESS_LOCKED:{endpoint} now refuses ALL unsolicited remote inbound; \
915	                         `spt access open {endpoint}` returns it to default-open"
916	                    );
917	                }
918	            }
919	            code
920	        }
921	        AccessCmd::Open { endpoint } => {
922	            let mut s = AccessStore::load();
923	            if !s.open(&endpoint) {
924	                eprintln!("ACCESS_ALREADY_OPEN:{endpoint} (no restriction existed)");
925	                return 0;
926	            }
927	            let code = save(&s);
928	            if code == 0 {
929	                eprintln!("ACCESS_OPEN:{endpoint} (back to default-open)");
930	            }
931	            code
932	        }
933	        AccessCmd::List { endpoint } => {
934	            let s = AccessStore::load();
935	            match endpoint {
936	                Some(ep) => match s.lookup(&ep) {
937	                    None => println!("OPEN:{ep} (no restriction; any visible node may reach it)"),
938	                    Some(e) if e.nodes.is_empty() => println!("LOCKED:{ep} (refuses all unsolicited remote inbound)"),
939	                    Some(e) => println!("RESTRICTED:{ep}: {}", e.nodes.join(", ")),
940	                },
941	                None => {
942	                    if s.entries.is_empty() {
943	                        println!("no restrictions (every endpoint default-open)");
944	                    }
945	                    for e in &s.entries {
946	                        if e.nodes.is_empty() {
947	                            println!("LOCKED:{}", e.endpoint);
948	                        } else {
949	                            println!("RESTRICTED:{}: {}", e.endpoint, e.nodes.join(", "));
950	                        }
951	                    }
952	                }
953	            }
954	            0
955	        }
956	    }
957	}
958
959	fn cmd_rename(old_id: &str, new_id: &str) -> i32 {
960	    if let Err(e) = spt_proto::id::validate_endpoint_id(new_id) {
961	        eprintln!("ERROR: invalid new id: {e}");
962	        return 2;
963	    }
964	    // Context-store collision pre-check (an existing a-<new> mind refuses —
965	    // never merge two minds) BEFORE the perch arm mutates anything; the actual
966	    // branch ripple runs after the perch arm's own refusals (live/collision)
967	    // have passed, so a refusal on either side leaves the other untouched.
968	    // [impl->REQ-INST-11]
969	    let cs = spt_store::contextstore::ContextStore::open_or_init().ok();
970	    if let Some(cs) = &cs {
971	        match cs.branch_store().has_branch(&spt_store::contextstore::agent_branch(new_id)) {
972	            Ok(true) => {
973	                eprintln!("ERROR: context branch already exists: a-{new_id}");
974	                return 1;
975	            }
976	            Ok(false) => {}
977	            Err(e) => {
978	                eprintln!("ERROR: context store: {e}");
979	                return 1;
980	            }
981	        }
982	    }
983	    match spt_store::rename::rename_perch_in(&perch::owlery_dir(), old_id, new_id) {
984	        Ok(rippled) => {
985	            if let Some(cs) = &cs {
986	                // The a-/p- branch ripple (D6a). The collision was pre-checked;
987	                // a residual failure here is surfaced with the recovery hint.
988	                if let Err(e) = cs.rename_endpoint(old_id, new_id) {
989	                    eprintln!(
990	                        "ERROR: perches renamed but context rename failed: {e}\n\
991	                         recover: rerun `spt rename {old_id} {new_id}` after fixing the cause \
992	                         (perch arm will report NotFound and the context arm will retry)"
993	                    );
994	                    return 1;
995	                }
996	            }
997	            for id in &rippled {
998	                println!("RENAMED:{id}");
999	            }
1000	            eprintln!("renamed {old_id} -> {new_id} ({} perch(es))", rippled.len());
1001	            0
1002	        }
1003	        Err(spt_store::rename::RenameDirError::NotFound(_))
1004	            if cs
1005	                .as_ref()
1006	                .and_then(|cs| {
1007	                    cs.branch_store()
1008	                        .has_branch(&spt_store::contextstore::agent_branch(old_id))
1009	                        .ok()
1010	                })
1011	                .unwrap_or(false) =>
1012	        {
1013	            // No perch under the old id but a tracked mind exists — the retry
1014	            // path after a half-completed rename: finish the context arm.
1015	            match cs.as_ref().expect("guard checked").rename_endpoint(old_id, new_id) {
1016	                Ok(()) => {
1017	                    eprintln!("renamed {old_id} -> {new_id} (context only; no perch)");
1018	                    0
1019	                }
1020	                Err(e) => {
1021	                    eprintln!("ERROR: context rename: {e}");
1022	                    1
1023	                }
1024	            }
1025	        }
1026	        Err(e) => {
1027	            eprintln!("ERROR: {e}");
1028	            1
1029	        }
1030	    }
1031	}
1032
1033	fn now_unix() -> u64 {
1034	    std::time::SystemTime::now()
1035	        .duration_since(std::time::UNIX_EPOCH)
1036	        .map(|d| d.as_secs())
1037	        .unwrap_or(0)
1038	}
1039
1040	#[cfg(test)]
1041	mod tests {
1042	    use super::*;
1043
1044	    fn parse(args: &[&str]) -> Result<Cli, clap::Error> {
1045	        Cli::try_parse_from(args)
1046	    }
1047
1048	    // [unit->REQ-MSG-2] the documented subcommands + arg shapes parse.
1049	    #[test]
1050	    fn subcommands_parse() {
1051	        assert!(matches!(parse(&["spt", "send", "bob", "--from", "alice"]).unwrap().cmd, Cmd::Send { .. }));
1052	        assert!(matches!(parse(&["spt", "send", "bob", "--deferred"]).unwrap().cmd, Cmd::Send { deferred: true, .. }));
1053	        assert!(matches!(parse(&["spt", "ring", "bob", "--timeout", "5"]).unwrap().cmd, Cmd::Ring { .. }));
1054	        assert!(matches!(parse(&["spt", "ready", "bob"]).unwrap().cmd, Cmd::Ready { .. }));
1055	        assert!(matches!(parse(&["spt", "poll", "bob", "--once"]).unwrap().cmd, Cmd::Poll { once: true, .. }));
1056	        assert!(matches!(parse(&["spt", "list"]).unwrap().cmd, Cmd::List));
1057	        assert!(matches!(parse(&["spt", "stop", "bob"]).unwrap().cmd, Cmd::Stop { .. }));
1058	        assert!(matches!(parse(&["spt", "whoami"]).unwrap().cmd, Cmd::Whoami));
1059	        // The hidden daemon entry point parses (auto-started by `api`).
1060	        assert!(matches!(parse(&["spt", "daemon"]).unwrap().cmd, Cmd::Daemon));
1061	        // [unit->REQ-INST-11] the rename surface parses (two positional ids).
1062	        assert!(matches!(parse(&["spt", "rename", "ling", "oak"]).unwrap().cmd, Cmd::Rename { .. }));
1063	        assert!(parse(&["spt", "rename", "ling"]).is_err(), "new id required");
1064	        // The digest surface + its flags.
1065	        assert!(matches!(parse(&["spt", "digest", "doyle"]).unwrap().cmd, Cmd::Digest { .. }));
1066	        assert!(matches!(
1067	            parse(&["spt", "digest", "doyle", "--follow", "--json"]).unwrap().cmd,
1068	            Cmd::Digest { follow: true, json: true, .. }
1069	        ));
1070	    }
1071
1072	    // [unit->REQ-NOTIF-2] the notify/notif surfaces parse: notify takes an
1073	    // optional body + --subnet/--from; notif list/dismiss shapes hold.
1074	    #[test]
1075	    fn notify_surface_parses() {
1076	        assert!(matches!(
1077	            parse(&["spt", "notify", "build done", "--subnet", "home", "--from", "doyle"])
1078	                .unwrap()
1079	                .cmd,
1080	            Cmd::Notify { .. }
1081	        ));
1082	        assert!(matches!(parse(&["spt", "notify"]).unwrap().cmd, Cmd::Notify { body: None, .. }));
1083	        assert!(matches!(
1084	            parse(&["spt", "notif", "list", "--subnet", "home"]).unwrap().cmd,
1085	            Cmd::Notif { action: NotifCmd::List { .. } }
1086	        ));
1087	        assert!(matches!(
1088	            parse(&["spt", "notif", "dismiss", "cafe:7"]).unwrap().cmd,
1089	            Cmd::Notif { action: NotifCmd::Dismiss { .. } }
1090	        ));
1091	        assert!(parse(&["spt", "notif", "dismiss"]).is_err(), "notif id required");
1092	    }
1093
1094	    // [unit->REQ-NOTIF-2] subnet resolution refuses to guess: explicit
1095	    // non-member refused; zero subnets refused; exactly one auto-selects;
1096	    // several force --subnet.
1097	    #[test]
1098	    fn notif_subnet_resolution_never_guesses() {
1099	        let mut store = SubnetStore::default();
1100	        assert_eq!(resolve_notif_subnet(&store, None), Err(1), "no subnet => refuse");
1101	        assert_eq!(
1102	            resolve_notif_subnet(&store, Some("home".into())),
1103	            Err(1),
1104	            "non-member => refuse"
1105	        );
1106
1107	        store.create_subnet("home").unwrap();
1108	        assert_eq!(resolve_notif_subnet(&store, None), Ok("home".to_string()), "sole subnet");
1109	        assert_eq!(resolve_notif_subnet(&store, Some("home".into())), Ok("home".to_string()));
1110
1111	        store.create_subnet("work").unwrap();
1112	        assert_eq!(resolve_notif_subnet(&store, None), Err(1), "several => qualify");
1113	        assert_eq!(resolve_notif_subnet(&store, Some("work".into())), Ok("work".to_string()));
1114	    }
1115
1116	    // [unit->REQ-SEC-1] the access whitelist surface parses: allow/revoke take
1117	    // (endpoint, node); open takes the endpoint; list takes an optional one.
1118	    #[test]
1119	    fn access_subcommands_parse() {
1120	        assert!(matches!(
1121	            parse(&["spt", "access", "allow", "ling", "aa11"]).unwrap().cmd,
1122	            Cmd::Access { action: AccessCmd::Allow { .. } }
1123	        ));
1124	        assert!(matches!(
1125	            parse(&["spt", "access", "revoke", "ling", "aa11"]).unwrap().cmd,
1126	            Cmd::Access { action: AccessCmd::Revoke { .. } }
1127	        ));
1128	        assert!(matches!(
1129	            parse(&["spt", "access", "open", "ling"]).unwrap().cmd,
1130	            Cmd::Access { action: AccessCmd::Open { .. } }
1131	        ));
1132	        assert!(matches!(
1133	            parse(&["spt", "access", "list"]).unwrap().cmd,
1134	            Cmd::Access { action: AccessCmd::List { endpoint: None } }
1135	        ));
1136	        assert!(matches!(
1137	            parse(&["spt", "access", "list", "ling"]).unwrap().cmd,
1138	            Cmd::Access { action: AccessCmd::List { endpoint: Some(_) } }
1139	        ));
1140	        assert!(parse(&["spt", "access", "allow", "ling"]).is_err(), "node required");
1141	    }
1142
1143	    // [unit->REQ-TERM-4] digest defaults: a one-shot, human-rendered snapshot.
1144	    #[test]
1145	    fn digest_flag_defaults() {
1146	        match parse(&["spt", "digest", "doyle"]).unwrap().cmd {
1147	            Cmd::Digest { id, follow, json } => {
1148	                assert_eq!(id, "doyle");
1149	                assert!(!follow, "snapshot by default");
1150	                assert!(!json, "human render by default");
1151	            }
1152	            _ => panic!("expected Digest"),
1153	        }
1154	    }
1155
1156	    // [unit->REQ-MSG-2] ring timeout defaults to 60s when unspecified.
1157	    #[test]
1158	    fn ring_timeout_defaults() {
1159	        match parse(&["spt", "ring", "bob"]).unwrap().cmd {
1160	            Cmd::Ring { timeout, .. } => assert_eq!(timeout, 60),
1161	            _ => panic!("expected Ring"),
1162	        }
1163	    }
1164
1165	    // [unit->REQ-MSG-2] send target is optional (supplied via --reply-to instead).
1166	    #[test]
1167	    fn send_target_optional_with_reply_to() {
1168	        match parse(&["spt", "send", "--reply-to", "alice"]).unwrap().cmd {
1169	            Cmd::Send { target, reply_to, .. } => {
1170	                assert!(target.is_none());
1171	                assert_eq!(reply_to.as_deref(), Some("alice"));
1172	            }
1173	            _ => panic!("expected Send"),
1174	        }
1175	    }
1176
1177	    // [unit->REQ-MSG-2] unknown subcommand / missing required arg are usage errors.
1178	    #[test]
1179	    fn invalid_usage_errors() {
1180	        assert!(parse(&["spt", "bogus"]).is_err());
1181	        assert!(parse(&["spt", "ring"]).is_err()); // missing required target
1182	    }
1183
1184	    // ── pair show-totp ─────────────────────────────────────────────────────────
1185
1186	    fn show_totp(args: &[&str]) -> PairCmd {
1187	        match parse(args).unwrap().cmd {
1188	            Cmd::Pair { action } => action,
1189	            _ => panic!("expected Pair"),
1190	        }
1191	    }
1192
1193	    // [unit->REQ-PAIR-3] the show-totp arg shapes parse: bare, --subnet, --create-new.
1194	    #[test]
1195	    fn pair_show_totp_arg_shapes_parse() {
1196	        assert!(matches!(
1197	            show_totp(&["spt", "pair", "show-totp"]),
1198	            PairCmd::ShowTotp { subnet: None, create_new: None }
1199	        ));
1200	        assert!(matches!(
1201	            show_totp(&["spt", "pair", "show-totp", "--subnet", "home"]),
1202	            PairCmd::ShowTotp { subnet: Some(s), .. } if s == "home"
1203	        ));
1204	        assert!(matches!(
1205	            show_totp(&["spt", "pair", "show-totp", "--create-new", "home"]),
1206	            PairCmd::ShowTotp { create_new: Some(s), .. } if s == "home"
1207	        ));
1208	    }
1209
1210	    // [unit->REQ-PAIR-3] --subnet and --create-new are mutually exclusive.
1211	    #[test]
1212	    fn pair_show_totp_subnet_and_create_new_conflict() {
1213	        assert!(parse(&["spt", "pair", "show-totp", "--subnet", "a", "--create-new", "b"]).is_err());
1214	    }
1215
1216	    fn store_with(names: &[&str]) -> SubnetStore {
1217	        let mut s = SubnetStore::default();
1218	        for n in names {
1219	            s.create_subnet(n).expect("create");
1220	        }
1221	        s
1222	    }
1223
1224	    // [unit->REQ-PAIR-3] a lone subnet is auto-selected with no flag; elevated, the
1225	    // printed code matches TotpSeed computed from that subnet's stored seed.
1226	    #[test]
1227	    fn single_subnet_auto_selects_and_code_matches_seed() {
1228	        let mut store = store_with(&["home"]);
1229	        let seed = store.find("home").unwrap().seed_bytes().unwrap();
1230	        let now = 1_111_111_109;
1231	        let r = decide_show_totp(resolve(&mut store, None, None), now, Elevation::Elevated);
1232	        assert_eq!(r.code, 0);
1233	        let expected = TotpSeed::from_bytes(seed).code_at(now);
1234	        let out = r.out.expect("elevated show prints the code");
1235	        assert!(out.starts_with(&expected), "stdout starts with the code");
1236	        assert!(out.contains("otpauth://"), "stdout carries the provisioning URI");
1237	        assert!(!r.save, "an existing subnet is not re-persisted");
1238	    }
1239
1240	    // [unit->REQ-PAIR-3] several subnets + no --subnet refuses (no guessing); naming
1241	    // one resolves it.
1242	    #[test]
1243	    fn multi_subnet_refuses_without_flag_then_resolves_with_it() {
1244	        let mut store = store_with(&["home", "work"]);
1245	        let amb = decide_show_totp(resolve(&mut store, None, None), 0, Elevation::Elevated);
1246	        assert_eq!(amb.code, 2);
1247	        assert!(amb.out.is_none());
1248	        assert!(amb.msg.contains("AMBIGUOUS_SUBNET"));
1249
1250	        let work_seed = store.find("work").unwrap().seed_bytes().unwrap();
1251	        let now = 1_234_567_890;
1252	        let r = decide_show_totp(
1253	            resolve(&mut store, Some("work".into()), None),
1254	            now,
1255	            Elevation::Elevated,
1256	        );
1257	        assert_eq!(r.code, 0);
1258	        assert!(r.out.unwrap().starts_with(&TotpSeed::from_bytes(work_seed).code_at(now)));
1259	    }
1260
1261	    // [unit->REQ-PAIR-3] --create-new on a fresh name mints + shows, and flags the
1262	    // store for persistence (save = true) on the elevated path.
1263	    #[test]
1264	    fn create_new_mints_shows_and_requests_save() {
1265	        let mut store = SubnetStore::default();
1266	        let r = decide_show_totp(
1267	            resolve(&mut store, None, Some("fresh".into())),
1268	            42,
1269	            Elevation::Elevated,
1270	        );
1271	        assert_eq!(r.code, 0);
1272	        assert!(r.save, "a freshly minted subnet must be persisted on show");
1273	        assert!(r.out.unwrap().contains("otpauth://"));
1274	        assert!(store.find("fresh").is_some(), "mint landed in the in-memory store");
1275	    }
1276
1277	    // [unit->REQ-PAIR-3] --create-new for an existing name is a usage error.
1278	    #[test]
1279	    fn create_new_existing_name_is_usage_error() {
1280	        let mut store = store_with(&["home"]);
1281	        let r = decide_show_totp(
1282	            resolve(&mut store, None, Some("home".into())),
1283	            0,
1284	            Elevation::Elevated,
1285	        );
1286	        assert_eq!(r.code, 2);
1287	        assert!(r.msg.contains("SUBNET_EXISTS"));
1288	    }
1289
1290	    // [unit->REQ-PAIR-3] zero subnets + no flag points the user at pairing / --create-new.
1291	    #[test]
1292	    fn empty_store_no_flag_guides_to_pair_or_create() {
1293	        let mut store = SubnetStore::default();
1294	        let r = decide_show_totp(resolve(&mut store, None, None), 0, Elevation::Elevated);
1295	        assert_eq!(r.code, 2);
1296	        assert!(r.msg.contains("NO_SUBNETS"));
1297	    }
1298
1299	    // [unit->REQ-PAIR-3] a named-but-absent subnet is a usage error.
1300	    #[test]
1301	    fn unknown_named_subnet_is_usage_error() {
1302	        let mut store = store_with(&["home"]);
1303	        let r = decide_show_totp(
1304	            resolve(&mut store, Some("ghost".into()), None),
1305	            0,
1306	            Elevation::Elevated,
1307	        );
1308	        assert_eq!(r.code, 2);
1309	        assert!(r.msg.contains("NO_SUCH_SUBNET:ghost"));
1310	    }
1311
1312	    // [unit->REQ-PAIR-6] not elevated: the fetch is refused — distinct exit code, NO
1313	    // code in stdout (no leak), fallback message names the authenticator app, and a
1314	    // would-be --create-new mint is NOT persisted (refused = no durable side effect).
1315	    #[test]
1316	    fn not_elevated_refuses_without_leaking_code_or_saving() {
1317	        let mut store = SubnetStore::default();
1318	        let r = decide_show_totp(
1319	            resolve(&mut store, None, Some("fresh".into())),
1320	            0,
1321	            Elevation::NotElevated,
1322	        );
1323	        assert_eq!(r.code, EXIT_NOT_ELEVATED);
1324	        assert!(r.out.is_none(), "no code leaks on the refused path");
1325	        assert!(r.msg.contains("authenticator app"));
1326	        assert!(!r.save, "a refused fetch persists no mint");
1327	    }
1328
1329	    // [unit->REQ-PAIR-6] an undeterminable elevation (Unknown) also falls back — the
1330	    // same fail-safe path as NotElevated, never leaking the code.
1331	    #[test]
1332	    fn unknown_elevation_falls_back() {
1333	        let mut store = store_with(&["home"]);
1334	        let r = decide_show_totp(resolve(&mut store, None, None), 0, Elevation::Unknown);
1335	        assert_eq!(r.code, EXIT_NOT_ELEVATED);
1336	        assert!(r.out.is_none());
1337	    }
1338	}
1339

//! CLI surface + dispatch for the `spt` binary (REQ-MSG-2).
//!
//! Stable arg shapes and exit codes are the contract here. Each subcommand is a
//! thin shim: read I/O, call the matching `spt-msg` entry point, render the
//! structured outcome, return an exit code. No messaging logic lives in this
//! layer.
//!
//! Exit codes: `0` success (delivered, queued, replied, listed) · `1` runtime
//! refusal (no perch, empty body, unidentifiable self) · `2` usage error
//! (clap, for unknown/missing args).
// [impl->REQ-MSG-2]

use std::io::{Read, Write};
use std::time::Duration;

use clap::{Parser, Subcommand};
use spt_msg::deliver::{self, SendOutcome};
use spt_msg::ready::ReadyAgent;
use spt_msg::ring::{self, RingOutcome};
use spt_net::net::pairing::totp::TotpSeed;
use spt_store::perch::{self, ParentHint};
use spt_store::registry;
use spt_store::subnet::{SubnetError, SubnetStore};

use crate::elevation::{self, Elevation, Gate};
use crate::roster;

/// Product tag embedded in the `otpauth://` provisioning URI (the authenticator-app
/// issuer label). Stable user-facing string.
const ISSUER: &str = "spt";

/// `spt pair show-totp` refused the fetch for lack of OS elevation. Distinct from the
/// crate-wide codes (`0` ok · `1` runtime refusal · `2` usage): `3` says the fetch was
/// *refused, not failed* — re-run elevated, or read the code from your authenticator app.
const EXIT_NOT_ELEVATED: i32 = 3;

#[derive(Parser)]
#[command(name = "spt", version, about = "spt-core local messaging", long_about = None)]
struct Cli {
    #[command(subcommand)]
    cmd: Cmd,
}

#[derive(Subcommand)]
enum Cmd {
    /// Send a message (body read from stdin). Fire-and-forget.
    Send {
        /// Target perch id. May be omitted when --reply-to is given.
        target: Option<String>,
        /// Sender id written into __REPLY_TO__ (auto-detected from session if omitted).
        #[arg(long)]
        from: Option<String>,
        /// Reply to this sender; sets the target and labels output REPLIED.
        #[arg(long)]
        reply_to: Option<String>,
        /// Spool only (deferred / hook channel) — no live wake.
        #[arg(long)]
        deferred: bool,
    },
    /// Send and block for a reply (body read from stdin; reply printed to stdout).
    Ring {
        /// Target perch id.
        target: String,
        /// Sender id (auto-detected from session if omitted).
        #[arg(long)]
        from: Option<String>,
        /// Seconds to wait for a reply before giving up.
        #[arg(long, default_value = "60")]
        timeout: u64,
    },
    /// Become reachable: register the perch and listen for messages (blocks).
    Ready {
        /// This agent's perch id.
        id: String,
    },
    /// Receive messages: drain backlog then listen, printing each to stdout.
    Poll {
        /// This agent's perch id.
        id: String,
        /// Run a single drain+receive cycle, then exit (legacy one-shot).
        #[arg(long)]
        once: bool,
    },
    /// List perches under the owlery (id, state, address, ready, alive).
    List,
    /// Soft-stop a perch: remove the ready marker + unregister (spool preserved).
    Stop {
        /// Perch id to stop.
        id: String,
    },
    /// Print this session's own perch id (from $OWL_SESSION_ID / $SPT_AGENT_ID).
    Whoami,
    /// Subnet pairing operations (REQ-PAIR-*). Local-only here — the cross-node
    /// ceremony rides spt-net (M4 D4+).
    Pair {
        #[command(subcommand)]
        action: PairCmd,
    },
    /// Show a daemon-hosted session's live activity buffer (PTY digest) — the
    /// at-a-glance "what is this agent doing now" view (ADR-0008). Pulls a
    /// snapshot, or `--follow`s the delta-stream. Local addressing only (the
    /// `[subnet:]id@node` cross-node form is M4).
    Digest {
        /// The (local) endpoint id to read.
        id: String,
        /// Stream live changes instead of a one-shot snapshot (Ctrl-C to stop).
        #[arg(long)]
        follow: bool,
        /// Emit structured JSON instead of the human-glanceable render.
        #[arg(long)]
        json: bool,
    },
    /// Rename an endpoint's logical id, rippled on-disk (REQ-INST-11 / ADR-0006
    /// §2): the Self perch dir, its nested psyche/worker perches, and every
    /// `info.json`. Refuses while the perch is live (stop it first). The subnet
    /// registry rows ripple when the daemon-held registry lands (D4); the
    /// `a-<id>` context branch with the context store (D6).
    Rename {
        /// The endpoint's current (bare) id.
        old_id: String,
        /// The new (bare) id — charset-validated; `:`/`@` are reserved.
        new_id: String,
    },
    /// Endpoint access whitelist (ADR-0009, REQ-SEC-1): which origin nodes may
    /// send an endpoint unsolicited off-node inbound. Absent entry = open;
    /// `allow` flips the endpoint to restricted; revoking the last node leaves
    /// it locked down; `open` deletes the restriction.
    Access {
        #[command(subcommand)]
        action: AccessCmd,
    },
    /// Issue a subnet-wide user notification (ADR-0007, REQ-NOTIF-2): produced
    /// into the replicated notif spool and first-fired at the user's
    /// most-recently-active endpoint in that subnet. Body from the trailing
    /// arg, or stdin when omitted.
    Notify {
        /// Notification body (read from stdin when omitted).
        body: Option<String>,
        /// Which subnet to notify. Required only when the node holds several
        /// (no guessing — the `pair show-totp` stance).
        #[arg(long)]
        subnet: Option<String>,
        /// Issuer endpoint id (auto-detected from session if omitted).
        #[arg(long)]
        from: Option<String>,
    },
    /// Inspect and acknowledge notifications (ADR-0007). Dismissal is the
    /// explicit ack — it latches and replicates subnet-wide.
    Notif {
        #[command(subcommand)]
        action: NotifCmd,
    },
    /// Harness-contract inbound surface (REQ-API-1): the entry points a
    /// harness's hooks fire to mutate on-disk SPT state.
    Api(crate::api::ApiArgs),
    /// Run the per-machine broker/brain daemon (REQ-DAEMON-3). Internal: an
    /// `api` invocation auto-starts this; operators rarely run it directly.
    #[command(hide = true)]
    Daemon,
}

/// `spt access <action>` subcommands (REQ-SEC-1).
#[derive(Subcommand)]
enum AccessCmd {
    /// Whitelist a node for an endpoint (creates the restriction if absent).
    Allow {
        /// The endpoint to restrict.
        endpoint: String,
        /// The origin node's pubkey hex (as shown by `spt list` / pairing).
        node: String,
    },
    /// Remove a node from an endpoint's whitelist. Never widens: revoking the
    /// last node leaves the endpoint locked down (all unsolicited refused).
    Revoke {
        endpoint: String,
        node: String,
    },
    /// Delete an endpoint's restriction entirely — back to default-open.
    Open {
        endpoint: String,
    },
    /// List restrictions (all endpoints, or one).
    List {
        endpoint: Option<String>,
    },
}

/// `spt notif <action>` subcommands (ADR-0007).
#[derive(Subcommand)]
enum NotifCmd {
    /// List notifications (all member subnets, or one).
    List {
        /// Limit to one subnet.
        #[arg(long)]
        subnet: Option<String>,
    },
    /// Dismiss (ack) a notification by id — latches, replicates subnet-wide.
    Dismiss {
        /// The notif id (as shown by `spt notif list`).
        notif_id: String,
    },
}

/// `spt pair <action>` subcommands.
#[derive(Subcommand)]
enum PairCmd {
    /// Show a subnet's current 6-digit pairing code + `otpauth://` URI — the
    /// node-bound fetch, gated behind OS elevation (REQ-PAIR-3/6). With no flag the
    /// node's sole subnet is used; if it holds several, `--subnet <name>` is required
    /// (no guessing). `--create-new <name>` mints a fresh subnet first.
    ShowTotp {
        /// Which subnet's code to show. Required only when the node holds several.
        #[arg(long)]
        subnet: Option<String>,
        /// Mint a fresh subnet with this name (this node becomes the sole seed-holder),
        /// then show its code. Mutually exclusive with `--subnet`.
        #[arg(long, value_name = "NAME", conflicts_with = "subnet")]
        create_new: Option<String>,
    },
}

/// Parse args and dispatch. Returns the process exit code.
pub fn run() -> i32 {
    let cli = Cli::parse();
    match cli.cmd {
        Cmd::Send { target, from, reply_to, deferred } => cmd_send(target, from, reply_to, deferred),
        Cmd::Ring { target, from, timeout } => cmd_ring(target, from, timeout),
        Cmd::Ready { id } => run_listen(&id, false),
        Cmd::Poll { id, once } => run_listen(&id, once),
        Cmd::List => cmd_list(),
        Cmd::Stop { id } => cmd_stop(&id),
        Cmd::Whoami => cmd_whoami(),
        Cmd::Pair { action } => match action {
            PairCmd::ShowTotp { subnet, create_new } => cmd_pair_show_totp(subnet, create_new),
        },
        Cmd::Access { action } => cmd_access(action),
        Cmd::Notify { body, subnet, from } => cmd_notify(body, subnet, from),
        Cmd::Notif { action } => match action {
            NotifCmd::List { subnet } => cmd_notif_list(subnet),
            NotifCmd::Dismiss { notif_id } => cmd_notif_dismiss(&notif_id),
        },
        Cmd::Digest { id, follow, json } => cmd_digest(&id, follow, json),
        Cmd::Rename { old_id, new_id } => cmd_rename(&old_id, &new_id),
        Cmd::Api(args) => crate::api::run(args),
        Cmd::Daemon => cmd_daemon(),
    }
}

/// `spt digest <id>` — show a daemon-hosted session's live activity buffer
/// (REQ-TERM-4 / ADR-0008). Ensures the daemon is up, then pulls a snapshot or
/// follows the delta-stream over the digest-control channel. Local addressing
/// only: a qualified `[subnet:]id@node` form is refused (cross-node is M4).
// [impl->REQ-TERM-4]
fn cmd_digest(id: &str, follow: bool, json: bool) -> i32 {
    // Local addressing only — the qualified cross-node form is M4 (spt-net).
    if id.contains('@') || id.contains(':') {
        eprintln!("CROSS_NODE_M4:{id} — qualified [subnet:]id@node addressing is M4, not yet supported");
        return 1;
    }
    // The digest lives in the daemon; bring it up if absent (best-effort).
    if let Err(e) = spt_daemon::ensure_running() {
        eprintln!("DAEMON_START_WARN: {e}");
    }
    let name = spt_daemon::digest_socket_name();

    if follow {
        let res = spt_daemon::follow(&name, id, 0, |up| {
            if json {
                println!("{}", spt_daemon::update_to_json(up));
            } else {
                print!("{}", spt_daemon::render_update(up));
            }
            let _ = std::io::stdout().flush();
            true // stream until the connection ends / the user interrupts
        });
        return match res {
            Ok(()) => 0,
            Err(e) => {
                eprintln!("DIGEST_FOLLOW_FAIL:{id}: {e}");
                1
            }
        };
    }

    match spt_daemon::pull_snapshot(&name, id) {
        Ok(Some((digest, version))) => {
            if json {
                println!("{}", spt_daemon::digest_to_json(&digest));
            } else {
                print!("{}", spt_daemon::render_digest(&digest));
                let _ = std::io::stdout().flush();
            }
            eprintln!("DIGEST:{id} version={version}");
            0
        }
        Ok(None) => {
            eprintln!("NO_DIGEST:{id} has no live activity buffer (not a daemon-hosted session?)");
            1
        }
        Err(e) => {
            eprintln!("DIGEST_FAIL:{id}: {e}");
            1
        }
    }
}

/// Run the per-machine daemon (the hidden `spt daemon` entry point). Blocks
/// serving the broker + seed-control channels until killed; a bind contention
/// (another daemon already owns this home) surfaces as a non-zero exit.
// [impl->REQ-DAEMON-3]
fn cmd_daemon() -> i32 {
    match spt_daemon::Daemon::run() {
        Ok(()) => 0,
        Err(e) => {
            eprintln!("DAEMON_EXIT: {e}");
            1
        }
    }
}

/// Read stdin to a string (message body source for send/ring).
fn read_stdin() -> String {
    let mut body = String::new();
    let _ = std::io::stdin().read_to_string(&mut body);
    body
}

/// Resolve which subnet a notif targets: an explicit `--subnet` must be a
/// member; with no flag the node's sole subnet is used; several ⇒ refuse and
/// force qualification (no guessing — the D3c/`show-totp` stance).
// [impl->REQ-NOTIF-2]
fn resolve_notif_subnet(store: &SubnetStore, flag: Option<String>) -> Result<String, i32> {
    match flag {
        Some(name) => {
            if store.find(&name).is_some() {
                Ok(name)
            } else {
                eprintln!("NO_SUBNET:{name} — this node is not a member");
                Err(1)
            }
        }
        None => match store.subnets.len() {
            0 => {
                eprintln!("NO_SUBNET: this node holds no subnet — pair first");
                Err(1)
            }
            1 => Ok(store.subnets[0].name.clone()),
            _ => {
                eprintln!(
                    "AMBIGUOUS_SUBNET: this node holds several — pass --subnet <name> (no guessing)"
                );
                Err(1)
            }
        },
    }
}

/// `spt notify` — agent-issued subnet notif (ADR-0007, REQ-NOTIF-2): produce
/// into the replicated spool (id minted from this node's EpochSource), then
/// first-fire at the user's most-recently-active visible endpoint. The
/// envelope `from` carries issuer endpoint **and node** (`<id>@<node-hex>`);
/// the subnet rides the envelope's own attribute.
// [impl->REQ-NOTIF-2]
fn cmd_notify(body: Option<String>, subnet: Option<String>, from: Option<String>) -> i32 {
    let issuer = from.or_else(roster::detect_self_id).unwrap_or_default();
    if issuer.is_empty() {
        eprintln!("NO_SELF: notify needs an issuer (--from <id> or $OWL_SESSION_ID)");
        return 1;
    }
    let subnets = SubnetStore::load();
    let subnet = match resolve_notif_subnet(&subnets, subnet) {
        Ok(s) => s,
        Err(code) => return code,
    };
    let body = body.unwrap_or_else(read_stdin);
    let body = body.trim();
    if body.is_empty() {
        eprintln!("EMPTY_BODY: nothing to notify");
        return 1;
    }
    let node_hex = match spt_store::nodeid::load_or_create() {
        Ok(identity) => identity.public_key().to_hex(),
        Err(e) => {
            eprintln!("NODE_ID_FAIL: {e}");
            return 1;
        }
    };
    let store = match spt_store::notif::NotifStore::open() {
        Ok(s) => s,
        Err(e) => {
            eprintln!("NOTIF_STORE_FAIL: {e}");
            return 1;
        }
    };
    let mut epochs = spt_store::epoch::EpochSource::load();
    let policy = spt_daemon::NotifSurfacePolicy::load();
    let from_id = format!("{issuer}@{node_hex}");
    match spt_daemon::produce_and_first_fire(
        &store,
        &node_hex,
        &mut epochs,
        &subnet,
        spt_daemon::NOTIF_KIND_AGENT,
        &from_id,
        body,
        &policy,
        &perch::owlery_dir(),
        now_ms(),
    ) {
        Ok((row, spt_daemon::FirstFireOutcome::Fired { to, .. })) => {
            println!("NOTIFIED:{} -> {to}", row.notif_id);
            0
        }
        Ok((row, spt_daemon::FirstFireOutcome::NoTarget)) => {
            println!(
                "NOTIF_PENDING:{} (no live endpoint in {subnet}; surfaces at the next boundary)",
                row.notif_id
            );
            0
        }
        Err(e) => {
            eprintln!("NOTIFY_FAIL: {e}");
            1
        }
    }
}

/// `spt notif list` — the ack surface's read side.
// [impl->REQ-NOTIF-2]
fn cmd_notif_list(subnet: Option<String>) -> i32 {
    let store = match spt_store::notif::NotifStore::open() {
        Ok(s) => s,
        Err(e) => {
            eprintln!("NOTIF_STORE_FAIL: {e}");
            return 1;
        }
    };
    let subnets = SubnetStore::load();
    let names: Vec<String> = match &subnet {
        Some(one) => vec![one.clone()],
        None => subnets.subnets.iter().map(|s| s.name.clone()).collect(),
    };
    let mut shown = 0usize;
    for name in names {
        for row in store.list(&name).unwrap_or_default() {
            let state = if row.dismissed {
                "dismissed".to_string()
            } else if row.seen.is_empty() {
                "fresh".to_string()
            } else {
                format!("seen:{}", row.seen.len())
            };
            // First body line only — the glanceable view.
            let head = row.body.lines().next().unwrap_or_default();
            println!("{}\t{}\t{}\t{}\t{}", row.notif_id, row.subnet, state, row.from_id, head);
            shown += 1;
        }
    }
    eprintln!("NOTIFS:{shown}");
    0
}

/// `spt notif dismiss <id>` — the explicit ack: latches locally, replicates
/// subnet-wide via the D8-2 feed.
// [impl->REQ-NOTIF-2]
fn cmd_notif_dismiss(notif_id: &str) -> i32 {
    let store = match spt_store::notif::NotifStore::open() {
        Ok(s) => s,
        Err(e) => {
            eprintln!("NOTIF_STORE_FAIL: {e}");
            return 1;
        }
    };
    match store.dismiss(notif_id) {
        Ok(true) => {
            println!("DISMISSED:{notif_id}");
            0
        }
        Ok(false) => {
            eprintln!("UNKNOWN_OR_DISMISSED:{notif_id}");
            1
        }
        Err(e) => {
            eprintln!("DISMISS_FAIL:{notif_id}: {e}");
            1
        }
    }
}

/// Epoch milliseconds (the notif surfacing clock).
fn now_ms() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0)
}

/// Resolve the sender id: explicit `--from`, else session auto-detect, else "".
fn resolve_from(from: Option<String>) -> String {
    from.or_else(roster::detect_self_id).unwrap_or_default()
}

fn cmd_send(target: Option<String>, from: Option<String>, reply_to: Option<String>, deferred: bool) -> i32 {
    let is_reply = reply_to.is_some();
    // --reply-to provides the target when no positional target is given.
    let target = match target.or_else(|| reply_to.clone()) {
        Some(t) => t,
        None => {
            eprintln!("USAGE: a target (or --reply-to <sender>) is required");
            return 2;
        }
    };
    let from = resolve_from(from);
    let body = read_stdin();
    let owlery = perch::owlery_dir();

    let outcome = if deferred {
        deliver::send_deferred(&target, &from, &body)
    } else {
        deliver::send(&target, &from, &body, &owlery)
    };

    match outcome {
        SendOutcome::Sent => {
            eprintln!("{}:{}", if is_reply { "REPLIED" } else { "SENT" }, target);
            0
        }
        SendOutcome::Queued => {
            let label = if deferred {
                "DEFERRED"
            } else if is_reply {
                "REPLIED(QUEUED)"
            } else {
                "QUEUED"
            };
            eprintln!("{}:{}", label, target);
            0
        }
        SendOutcome::NoPerch => {
            if deferred {
                // The wire record carries no deferred class (v1) — a remote
                // deferred send is a documented seam, not a silent reclass.
                eprintln!("NO_PERCH:{} is not listening (deferred stays local-only)", target);
                return 1;
            }
            // The WAN leg (D9-1-4): resolve against the registry snapshots
            // and ship to the instance's node; its daemon delivers/spools.
            // [impl->REQ-NET-1]
            match crate::wansend::wan_send(&target, &from, &body) {
                crate::wansend::WanSendOutcome::Sent { node } => {
                    eprintln!(
                        "{}:{}@{}",
                        if is_reply { "REPLIED(WAN)" } else { "SENT(WAN)" },
                        target,
                        node
                    );
                    0
                }
                crate::wansend::WanSendOutcome::Ambiguous(why) => {
                    eprintln!("AMBIGUOUS:{target} — qualify with subnet: or @node ({why})");
                    1
                }
                crate::wansend::WanSendOutcome::NotFound => {
                    eprintln!("NO_PERCH:{} is not listening", target);
                    1
                }
                crate::wansend::WanSendOutcome::Failed(e) => {
                    eprintln!("WAN_FAIL:{target} — {e}");
                    1
                }
            }
        }
        SendOutcome::Empty => {
            eprintln!("EMPTY_MSG: refusing to send empty message to {}", target);
            1
        }
    }
}

fn cmd_ring(target: String, from: Option<String>, timeout: u64) -> i32 {
    let from = resolve_from(from);
    if from.is_empty() {
        eprintln!("NO_SELF: ring needs a sender id (--from <id> or $OWL_SESSION_ID)");
        return 1;
    }
    let body = read_stdin();
    let owlery = perch::owlery_dir();

    match ring::ring(&target, &from, &body, Duration::from_secs(timeout), &owlery) {
        RingOutcome::Replied(reply) => {
            print!("{}", reply);
            let _ = std::io::stdout().flush();
            0
        }
        RingOutcome::TimedOut => {
            eprintln!("TIMEOUT:{} did not reply within {}s", target, timeout);
            0
        }
        RingOutcome::Queued => {
            eprintln!("QUEUED:{}", target);
            0
        }
        RingOutcome::Delivered => {
            eprintln!("SENT:{} (reply will arrive on your listener)", target);
            0
        }
        RingOutcome::NoPerch => {
            eprintln!("NO_PERCH:{} is not listening", target);
            1
        }
        RingOutcome::Empty => {
            eprintln!("EMPTY_MSG: refusing to ring {} with an empty message", target);
            1
        }
    }
}

/// Run the listener loop (REQ-MSG-3 lifecycle, driven from the CLI). Blocks
/// until the process is killed unless `once` is set. Each received message is
/// printed framed to stdout; status goes to stderr.
///
/// Interim M1 model: the perch is reachable only while this process runs. M3's
/// spt-daemon decouples registration from the live process.
fn run_listen(id: &str, once: bool) -> i32 {
    let (agent, backlog) = match ReadyAgent::start(id) {
        Ok(pair) => pair,
        Err(e) => {
            eprintln!("READY_FAIL:{}: {}", id, e);
            return 1;
        }
    };
    eprintln!("READY:{}", id);
    for msg in backlog {
        emit(&msg);
    }

    if once {
        if let Some(msg) = agent.recv() {
            emit(&msg);
        }
        for msg in agent.drain_spool() {
            emit(&msg);
        }
        return 0;
    }

    loop {
        if let Some(msg) = agent.recv() {
            emit(&msg);
        }
        for msg in agent.drain_spool() {
            emit(&msg);
        }
    }
}

/// Print a framed message to stdout and flush (one delivery per emit).
fn emit(msg: &str) {
    print!("{}", msg);
    let _ = std::io::stdout().flush();
}

fn cmd_list() -> i32 {
    let perches = roster::enumerate();
    if perches.is_empty() {
        eprintln!("(no perches)");
        return 0;
    }
    for p in perches {
        println!(
            "{:<28} {:<14} {:<22} ready={} alive={}",
            p.id,
            p.state,
            p.address.as_deref().unwrap_or("-"),
            p.ready,
            p.alive
        );
    }
    0
}

fn cmd_stop(id: &str) -> i32 {
    let owlery = perch::owlery_dir();
    // Soft-cleanup (HAZARD-SOFT-CLEANUP 6.2): remove the ready marker +
    // unregister the address; spool.db and info.json are preserved.
    let removed = std::fs::remove_file(perch::resolve_ready_file(id, ParentHint::Infer)).is_ok();
    let _ = registry::unregister_address(id, &owlery);
    if removed {
        eprintln!("STOPPED:{}", id);
    } else {
        eprintln!("STOPPED:{} (no ready marker; address unregistered)", id);
    }
    0
}

fn cmd_whoami() -> i32 {
    match roster::detect_self_id() {
        Some(id) => {
            println!("{}", id);
            0
        }
        None => {
            eprintln!("UNKNOWN: no perch matches this session ($OWL_SESSION_ID / $SPT_AGENT_ID)");
            1
        }
    }
}

/// What `show-totp` resolved its arguments to against the node's subnet store — the
/// platform-independent half of the command, decided before any OS elevation call so
/// it can be unit-tested hermetically.
enum Resolved {
    /// A subnet to surface: its name + raw seed bytes. `minted` = freshly created by
    /// `--create-new` (persist it only once the code is actually shown).
    Subnet { name: String, seed: [u8; spt_store::subnet::TOTP_SEED_LEN], minted: bool },
    /// Several subnets and no `--subnet` — refuse rather than guess.
    Ambiguous(Vec<String>),
    /// The node belongs to no subnet at all.
    Empty,
    /// A named subnet (`--subnet`) is not in the store.
    NotFound(String),
    /// `--create-new <name>` for a subnet that already exists.
    Exists(String),
    /// The stored seed is unreadable (corrupt hex/length) — re-pair to re-provision.
    Corrupt(String),
}

/// The rendered outcome of `show-totp`: an exit code, the optional stdout block (the
/// code + URI — present ONLY on the elevated show path, never leaked otherwise), a
/// stderr status line, and whether to persist a freshly-minted subnet. Pure over its
/// inputs (no IO, no OS calls) so every branch is unit-tested.
struct ShowResult {
    code: i32,
    out: Option<String>,
    msg: String,
    save: bool,
}

/// Resolve `show-totp` arguments against the store (mutating it iff `--create-new`
/// mints). The mint is in-memory only; the caller persists it just on the show path.
// [impl->REQ-PAIR-3]
fn resolve(store: &mut SubnetStore, subnet: Option<String>, create_new: Option<String>) -> Resolved {
    if let Some(name) = create_new {
        return match store.create_subnet(&name) {
            Ok(rec) => match rec.seed_bytes() {
                Some(seed) => Resolved::Subnet { name: rec.name.clone(), seed, minted: true },
                None => Resolved::Corrupt(rec.name.clone()),
            },
            Err(SubnetError::Exists(n)) => Resolved::Exists(n),
            Err(e) => Resolved::Corrupt(e.to_string()),
        };
    }
    if let Some(name) = subnet {
        return match store.find(&name) {
            Some(rec) => match rec.seed_bytes() {
                Some(seed) => Resolved::Subnet { name: rec.name.clone(), seed, minted: false },
                None => Resolved::Corrupt(rec.name.clone()),
            },
            None => Resolved::NotFound(name),
        };
    }
    // No flag: auto-select iff the node holds exactly one subnet (same no-guessing
    // ethos as D3c addressing — several is an error, not a default pick).
    match store.subnets.len() {
        0 => Resolved::Empty,
        1 => match store.subnets[0].seed_bytes() {
            Some(seed) => Resolved::Subnet { name: store.subnets[0].name.clone(), seed, minted: false },
            None => Resolved::Corrupt(store.subnets[0].name.clone()),
        },
        _ => Resolved::Ambiguous(store.subnets.iter().map(|s| s.name.clone()).collect()),
    }
}

/// Decide the rendered outcome from a resolution + the clock + the elevation level.
/// The elevation gate is applied LAST: a resolved subnet still falls back (and leaks
/// no code) unless the process is confirmed elevated.
// [impl->REQ-PAIR-3] [impl->REQ-PAIR-6]
fn decide_show_totp(resolved: Resolved, now: u64, elevation: Elevation) -> ShowResult {
    match resolved {
        Resolved::Subnet { name, seed, minted } => match elevation::gate_for(elevation) {
            Gate::Show => {
                let totp = TotpSeed::from_bytes(seed);
                // The URI carries the base32 secret — importable into an authenticator
                // app; QR *rendering* is GUI/later, so we print the URI text only.
                let out = format!("{}\n{}\n", totp.code_at(now), totp.otpauth_uri(ISSUER, &name));
                ShowResult { code: 0, out: Some(out), msg: format!("TOTP:{name} (elevated)"), save: minted }
            }
            // Refused fetch: no code, and we do NOT persist a `--create-new` mint —
            // a refused command leaves no durable side effect (re-run elevated).
            Gate::Fallback => ShowResult {
                code: EXIT_NOT_ELEVATED,
                out: None,
                msg: elevation::fallback_message(&name),
                save: false,
            },
        },
        Resolved::Ambiguous(names) => ShowResult {
            code: 2,
            out: None,
            msg: format!(
                "AMBIGUOUS_SUBNET: this node holds {} subnets ({}) — pass --subnet <name>",
                names.len(),
                names.join(", ")
            ),
            save: false,
        },
        Resolved::Empty => ShowResult {
            code: 2,
            out: None,
            msg: "NO_SUBNETS: this node belongs to no subnet — pair first, or mint one with --create-new <name>".to_string(),
            save: false,
        },
        Resolved::NotFound(n) => ShowResult {
            code: 2,
            out: None,
            msg: format!("NO_SUCH_SUBNET:{n}"),
            save: false,
        },
        Resolved::Exists(n) => ShowResult {
            code: 2,
            out: None,
            msg: format!("SUBNET_EXISTS:{n} — already a member; drop --create-new to show its code"),
            save: false,
        },
        Resolved::Corrupt(n) => ShowResult {
            code: 1,
            out: None,
            msg: format!("CORRUPT_SEED:{n} — stored seed is unreadable; re-pair to re-provision"),
            save: false,
        },
    }
}

/// `spt pair show-totp` — surface a subnet's current code off this node, behind the
/// OS-elevation gate (REQ-PAIR-3/6). Wires the real store, clock, and elevation probe
/// into the unit-tested [`resolve`]/[`decide_show_totp`] core.
// [impl->REQ-PAIR-3] [impl->REQ-PAIR-6]
fn cmd_pair_show_totp(subnet: Option<String>, create_new: Option<String>) -> i32 {
    let mut store = SubnetStore::load();
    let resolved = resolve(&mut store, subnet, create_new);
    let result = decide_show_totp(resolved, now_unix(), elevation::current());

    if result.save {
        if let Err(e) = store.save() {
            eprintln!("SAVE_FAIL: freshly minted subnet not persisted: {e}");
            return 1;
        }
    }
    if let Some(out) = result.out {
        print!("{out}");
        let _ = std::io::stdout().flush();
    }
    eprintln!("{}", result.msg);
    result.code
}

/// Current unix time in whole seconds (the TOTP clock input). A pre-epoch clock
/// degrades to step 0 rather than panicking.
/// `spt rename <old> <new>` — the on-disk rename ripple (REQ-INST-11). Validates
/// the new id (the `:`/`@` reserved-delimiter rule — a qualified string can never
/// become a bare id), then ripples the perch tree via
/// [`spt_store::rename::rename_perch_in`]. Prints each rippled perch id.
// [impl->REQ-INST-11]
/// `spt access allow|revoke|open|list` — operate the endpoint access whitelist
/// (`identity/trust/access.json`, ADR-0009). The daemon's gate re-reads the
/// store per check, so edits take effect on the next inbound — no restart.
// [impl->REQ-SEC-1]
fn cmd_access(action: AccessCmd) -> i32 {
    use spt_store::access::AccessStore;

    /// A node subject must be a 64-char Ed25519 pubkey hex — refuse typos
    /// rather than store a subject that can never match (a silent lockout).
    fn valid_node_hex(node: &str) -> bool {
        node.len() == 64 && node.chars().all(|c| c.is_ascii_hexdigit())
    }
    fn save(store: &AccessStore) -> i32 {
        match store.save() {
            Ok(()) => 0,
            Err(e) => {
                eprintln!("ACCESS_FAIL: {e}");
                1
            }
        }
    }

    match action {
        AccessCmd::Allow { endpoint, node } => {
            let node = node.to_lowercase();
            if !valid_node_hex(&node) {
                eprintln!("ACCESS_BAD_NODE: {node:?} is not a 64-hex node pubkey");
                return 1;
            }
            let mut s = AccessStore::load();
            s.allow(&endpoint, &node);
            let code = save(&s);
            if code == 0 {
                eprintln!("ACCESS_ALLOW:{endpoint}:{node} (endpoint is now restricted)");
            }
            code
        }
        AccessCmd::Revoke { endpoint, node } => {
            let node = node.to_lowercase();
            let mut s = AccessStore::load();
            if !s.revoke(&endpoint, &node) {
                eprintln!("ACCESS_NOT_LISTED:{endpoint}:{node} (nothing revoked)");
                return 1;
            }
            let locked = s.lookup(&endpoint).map(|e| e.nodes.is_empty()).unwrap_or(false);
            let code = save(&s);
            if code == 0 {
                eprintln!("ACCESS_REVOKE:{endpoint}:{node}");
                if locked {
                    eprintln!(
                        "ACCESS_LOCKED:{endpoint} now refuses ALL unsolicited remote inbound; \
                         `spt access open {endpoint}` returns it to default-open"
                    );
                }
            }
            code
        }
        AccessCmd::Open { endpoint } => {
            let mut s = AccessStore::load();
            if !s.open(&endpoint) {
                eprintln!("ACCESS_ALREADY_OPEN:{endpoint} (no restriction existed)");
                return 0;
            }
            let code = save(&s);
            if code == 0 {
                eprintln!("ACCESS_OPEN:{endpoint} (back to default-open)");
            }
            code
        }
        AccessCmd::List { endpoint } => {
            let s = AccessStore::load();
            match endpoint {
                Some(ep) => match s.lookup(&ep) {
                    None => println!("OPEN:{ep} (no restriction; any visible node may reach it)"),
                    Some(e) if e.nodes.is_empty() => println!("LOCKED:{ep} (refuses all unsolicited remote inbound)"),
                    Some(e) => println!("RESTRICTED:{ep}: {}", e.nodes.join(", ")),
                },
                None => {
                    if s.entries.is_empty() {
                        println!("no restrictions (every endpoint default-open)");
                    }
                    for e in &s.entries {
                        if e.nodes.is_empty() {
                            println!("LOCKED:{}", e.endpoint);
                        } else {
                            println!("RESTRICTED:{}: {}", e.endpoint, e.nodes.join(", "));
                        }
                    }
                }
            }
            0
        }
    }
}

fn cmd_rename(old_id: &str, new_id: &str) -> i32 {
    if let Err(e) = spt_proto::id::validate_endpoint_id(new_id) {
        eprintln!("ERROR: invalid new id: {e}");
        return 2;
    }
    // Context-store collision pre-check (an existing a-<new> mind refuses —
    // never merge two minds) BEFORE the perch arm mutates anything; the actual
    // branch ripple runs after the perch arm's own refusals (live/collision)
    // have passed, so a refusal on either side leaves the other untouched.
    // [impl->REQ-INST-11]
    let cs = spt_store::contextstore::ContextStore::open_or_init().ok();
    if let Some(cs) = &cs {
        match cs.branch_store().has_branch(&spt_store::contextstore::agent_branch(new_id)) {
            Ok(true) => {
                eprintln!("ERROR: context branch already exists: a-{new_id}");
                return 1;
            }
            Ok(false) => {}
            Err(e) => {
                eprintln!("ERROR: context store: {e}");
                return 1;
            }
        }
    }
    match spt_store::rename::rename_perch_in(&perch::owlery_dir(), old_id, new_id) {
        Ok(rippled) => {
            if let Some(cs) = &cs {
                // The a-/p- branch ripple (D6a). The collision was pre-checked;
                // a residual failure here is surfaced with the recovery hint.
                if let Err(e) = cs.rename_endpoint(old_id, new_id) {
                    eprintln!(
                        "ERROR: perches renamed but context rename failed: {e}\n\
                         recover: rerun `spt rename {old_id} {new_id}` after fixing the cause \
                         (perch arm will report NotFound and the context arm will retry)"
                    );
                    return 1;
                }
            }
            for id in &rippled {
                println!("RENAMED:{id}");
            }
            eprintln!("renamed {old_id} -> {new_id} ({} perch(es))", rippled.len());
            0
        }
        Err(spt_store::rename::RenameDirError::NotFound(_))
            if cs
                .as_ref()
                .and_then(|cs| {
                    cs.branch_store()
                        .has_branch(&spt_store::contextstore::agent_branch(old_id))
                        .ok()
                })
                .unwrap_or(false) =>
        {
            // No perch under the old id but a tracked mind exists — the retry
            // path after a half-completed rename: finish the context arm.
            match cs.as_ref().expect("guard checked").rename_endpoint(old_id, new_id) {
                Ok(()) => {
                    eprintln!("renamed {old_id} -> {new_id} (context only; no perch)");
                    0
                }
                Err(e) => {
                    eprintln!("ERROR: context rename: {e}");
                    1
                }
            }
        }
        Err(e) => {
            eprintln!("ERROR: {e}");
            1
        }
    }
}

fn now_unix() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn parse(args: &[&str]) -> Result<Cli, clap::Error> {
        Cli::try_parse_from(args)
    }

    // [unit->REQ-MSG-2] the documented subcommands + arg shapes parse.
    #[test]
    fn subcommands_parse() {
        assert!(matches!(parse(&["spt", "send", "bob", "--from", "alice"]).unwrap().cmd, Cmd::Send { .. }));
        assert!(matches!(parse(&["spt", "send", "bob", "--deferred"]).unwrap().cmd, Cmd::Send { deferred: true, .. }));
        assert!(matches!(parse(&["spt", "ring", "bob", "--timeout", "5"]).unwrap().cmd, Cmd::Ring { .. }));
        assert!(matches!(parse(&["spt", "ready", "bob"]).unwrap().cmd, Cmd::Ready { .. }));
        assert!(matches!(parse(&["spt", "poll", "bob", "--once"]).unwrap().cmd, Cmd::Poll { once: true, .. }));
        assert!(matches!(parse(&["spt", "list"]).unwrap().cmd, Cmd::List));
        assert!(matches!(parse(&["spt", "stop", "bob"]).unwrap().cmd, Cmd::Stop { .. }));
        assert!(matches!(parse(&["spt", "whoami"]).unwrap().cmd, Cmd::Whoami));
        // The hidden daemon entry point parses (auto-started by `api`).
        assert!(matches!(parse(&["spt", "daemon"]).unwrap().cmd, Cmd::Daemon));
        // [unit->REQ-INST-11] the rename surface parses (two positional ids).
        assert!(matches!(parse(&["spt", "rename", "ling", "oak"]).unwrap().cmd, Cmd::Rename { .. }));
        assert!(parse(&["spt", "rename", "ling"]).is_err(), "new id required");
        // The digest surface + its flags.
        assert!(matches!(parse(&["spt", "digest", "doyle"]).unwrap().cmd, Cmd::Digest { .. }));
        assert!(matches!(
            parse(&["spt", "digest", "doyle", "--follow", "--json"]).unwrap().cmd,
            Cmd::Digest { follow: true, json: true, .. }
        ));
    }

    // [unit->REQ-NOTIF-2] the notify/notif surfaces parse: notify takes an
    // optional body + --subnet/--from; notif list/dismiss shapes hold.
    #[test]
    fn notify_surface_parses() {
        assert!(matches!(
            parse(&["spt", "notify", "build done", "--subnet", "home", "--from", "doyle"])
                .unwrap()
                .cmd,
            Cmd::Notify { .. }
        ));
        assert!(matches!(parse(&["spt", "notify"]).unwrap().cmd, Cmd::Notify { body: None, .. }));
        assert!(matches!(
            parse(&["spt", "notif", "list", "--subnet", "home"]).unwrap().cmd,
            Cmd::Notif { action: NotifCmd::List { .. } }
        ));
        assert!(matches!(
            parse(&["spt", "notif", "dismiss", "cafe:7"]).unwrap().cmd,
            Cmd::Notif { action: NotifCmd::Dismiss { .. } }
        ));
        assert!(parse(&["spt", "notif", "dismiss"]).is_err(), "notif id required");
    }

    // [unit->REQ-NOTIF-2] subnet resolution refuses to guess: explicit
    // non-member refused; zero subnets refused; exactly one auto-selects;
    // several force --subnet.
    #[test]
    fn notif_subnet_resolution_never_guesses() {
        let mut store = SubnetStore::default();
        assert_eq!(resolve_notif_subnet(&store, None), Err(1), "no subnet => refuse");
        assert_eq!(
            resolve_notif_subnet(&store, Some("home".into())),
            Err(1),
            "non-member => refuse"
        );

        store.create_subnet("home").unwrap();
        assert_eq!(resolve_notif_subnet(&store, None), Ok("home".to_string()), "sole subnet");
        assert_eq!(resolve_notif_subnet(&store, Some("home".into())), Ok("home".to_string()));

        store.create_subnet("work").unwrap();
        assert_eq!(resolve_notif_subnet(&store, None), Err(1), "several => qualify");
        assert_eq!(resolve_notif_subnet(&store, Some("work".into())), Ok("work".to_string()));
    }

    // [unit->REQ-SEC-1] the access whitelist surface parses: allow/revoke take
    // (endpoint, node); open takes the endpoint; list takes an optional one.
    #[test]
    fn access_subcommands_parse() {
        assert!(matches!(
            parse(&["spt", "access", "allow", "ling", "aa11"]).unwrap().cmd,
            Cmd::Access { action: AccessCmd::Allow { .. } }
        ));
        assert!(matches!(
            parse(&["spt", "access", "revoke", "ling", "aa11"]).unwrap().cmd,
            Cmd::Access { action: AccessCmd::Revoke { .. } }
        ));
        assert!(matches!(
            parse(&["spt", "access", "open", "ling"]).unwrap().cmd,
            Cmd::Access { action: AccessCmd::Open { .. } }
        ));
        assert!(matches!(
            parse(&["spt", "access", "list"]).unwrap().cmd,
            Cmd::Access { action: AccessCmd::List { endpoint: None } }
        ));
        assert!(matches!(
            parse(&["spt", "access", "list", "ling"]).unwrap().cmd,
            Cmd::Access { action: AccessCmd::List { endpoint: Some(_) } }
        ));
        assert!(parse(&["spt", "access", "allow", "ling"]).is_err(), "node required");
    }

    // [unit->REQ-TERM-4] digest defaults: a one-shot, human-rendered snapshot.
    #[test]
    fn digest_flag_defaults() {
        match parse(&["spt", "digest", "doyle"]).unwrap().cmd {
            Cmd::Digest { id, follow, json } => {
                assert_eq!(id, "doyle");
                assert!(!follow, "snapshot by default");
                assert!(!json, "human render by default");
            }
            _ => panic!("expected Digest"),
        }
    }

    // [unit->REQ-MSG-2] ring timeout defaults to 60s when unspecified.
    #[test]
    fn ring_timeout_defaults() {
        match parse(&["spt", "ring", "bob"]).unwrap().cmd {
            Cmd::Ring { timeout, .. } => assert_eq!(timeout, 60),
            _ => panic!("expected Ring"),
        }
    }

    // [unit->REQ-MSG-2] send target is optional (supplied via --reply-to instead).
    #[test]
    fn send_target_optional_with_reply_to() {
        match parse(&["spt", "send", "--reply-to", "alice"]).unwrap().cmd {
            Cmd::Send { target, reply_to, .. } => {
                assert!(target.is_none());
                assert_eq!(reply_to.as_deref(), Some("alice"));
            }
            _ => panic!("expected Send"),
        }
    }

    // [unit->REQ-MSG-2] unknown subcommand / missing required arg are usage errors.
    #[test]
    fn invalid_usage_errors() {
        assert!(parse(&["spt", "bogus"]).is_err());
        assert!(parse(&["spt", "ring"]).is_err()); // missing required target
    }

    // ── pair show-totp ─────────────────────────────────────────────────────────

    fn show_totp(args: &[&str]) -> PairCmd {
        match parse(args).unwrap().cmd {
            Cmd::Pair { action } => action,
            _ => panic!("expected Pair"),
        }
    }

    // [unit->REQ-PAIR-3] the show-totp arg shapes parse: bare, --subnet, --create-new.
    #[test]
    fn pair_show_totp_arg_shapes_parse() {
        assert!(matches!(
            show_totp(&["spt", "pair", "show-totp"]),
            PairCmd::ShowTotp { subnet: None, create_new: None }
        ));
        assert!(matches!(
            show_totp(&["spt", "pair", "show-totp", "--subnet", "home"]),
            PairCmd::ShowTotp { subnet: Some(s), .. } if s == "home"
        ));
        assert!(matches!(
            show_totp(&["spt", "pair", "show-totp", "--create-new", "home"]),
            PairCmd::ShowTotp { create_new: Some(s), .. } if s == "home"
        ));
    }

    // [unit->REQ-PAIR-3] --subnet and --create-new are mutually exclusive.
    #[test]
    fn pair_show_totp_subnet_and_create_new_conflict() {
        assert!(parse(&["spt", "pair", "show-totp", "--subnet", "a", "--create-new", "b"]).is_err());
    }

    fn store_with(names: &[&str]) -> SubnetStore {
        let mut s = SubnetStore::default();
        for n in names {
            s.create_subnet(n).expect("create");
        }
        s
    }

    // [unit->REQ-PAIR-3] a lone subnet is auto-selected with no flag; elevated, the
    // printed code matches TotpSeed computed from that subnet's stored seed.
    #[test]
    fn single_subnet_auto_selects_and_code_matches_seed() {
        let mut store = store_with(&["home"]);
        let seed = store.find("home").unwrap().seed_bytes().unwrap();
        let now = 1_111_111_109;
        let r = decide_show_totp(resolve(&mut store, None, None), now, Elevation::Elevated);
        assert_eq!(r.code, 0);
        let expected = TotpSeed::from_bytes(seed).code_at(now);
        let out = r.out.expect("elevated show prints the code");
        assert!(out.starts_with(&expected), "stdout starts with the code");
        assert!(out.contains("otpauth://"), "stdout carries the provisioning URI");
        assert!(!r.save, "an existing subnet is not re-persisted");
    }

    // [unit->REQ-PAIR-3] several subnets + no --subnet refuses (no guessing); naming
    // one resolves it.
    #[test]
    fn multi_subnet_refuses_without_flag_then_resolves_with_it() {
        let mut store = store_with(&["home", "work"]);
        let amb = decide_show_totp(resolve(&mut store, None, None), 0, Elevation::Elevated);
        assert_eq!(amb.code, 2);
        assert!(amb.out.is_none());
        assert!(amb.msg.contains("AMBIGUOUS_SUBNET"));

        let work_seed = store.find("work").unwrap().seed_bytes().unwrap();
        let now = 1_234_567_890;
        let r = decide_show_totp(
            resolve(&mut store, Some("work".into()), None),
            now,
            Elevation::Elevated,
        );
        assert_eq!(r.code, 0);
        assert!(r.out.unwrap().starts_with(&TotpSeed::from_bytes(work_seed).code_at(now)));
    }

    // [unit->REQ-PAIR-3] --create-new on a fresh name mints + shows, and flags the
    // store for persistence (save = true) on the elevated path.
    #[test]
    fn create_new_mints_shows_and_requests_save() {
        let mut store = SubnetStore::default();
        let r = decide_show_totp(
            resolve(&mut store, None, Some("fresh".into())),
            42,
            Elevation::Elevated,
        );
        assert_eq!(r.code, 0);
        assert!(r.save, "a freshly minted subnet must be persisted on show");
        assert!(r.out.unwrap().contains("otpauth://"));
        assert!(store.find("fresh").is_some(), "mint landed in the in-memory store");
    }

    // [unit->REQ-PAIR-3] --create-new for an existing name is a usage error.
    #[test]
    fn create_new_existing_name_is_usage_error() {
        let mut store = store_with(&["home"]);
        let r = decide_show_totp(
            resolve(&mut store, None, Some("home".into())),
            0,
            Elevation::Elevated,
        );
        assert_eq!(r.code, 2);
        assert!(r.msg.contains("SUBNET_EXISTS"));
    }

    // [unit->REQ-PAIR-3] zero subnets + no flag points the user at pairing / --create-new.
    #[test]
    fn empty_store_no_flag_guides_to_pair_or_create() {
        let mut store = SubnetStore::default();
        let r = decide_show_totp(resolve(&mut store, None, None), 0, Elevation::Elevated);
        assert_eq!(r.code, 2);
        assert!(r.msg.contains("NO_SUBNETS"));
    }

    // [unit->REQ-PAIR-3] a named-but-absent subnet is a usage error.
    #[test]
    fn unknown_named_subnet_is_usage_error() {
        let mut store = store_with(&["home"]);
        let r = decide_show_totp(
            resolve(&mut store, Some("ghost".into()), None),
            0,
            Elevation::Elevated,
        );
        assert_eq!(r.code, 2);
        assert!(r.msg.contains("NO_SUCH_SUBNET:ghost"));
    }

    // [unit->REQ-PAIR-6] not elevated: the fetch is refused — distinct exit code, NO
    // code in stdout (no leak), fallback message names the authenticator app, and a
    // would-be --create-new mint is NOT persisted (refused = no durable side effect).
    #[test]
    fn not_elevated_refuses_without_leaking_code_or_saving() {
        let mut store = SubnetStore::default();
        let r = decide_show_totp(
            resolve(&mut store, None, Some("fresh".into())),
            0,
            Elevation::NotElevated,
        );
        assert_eq!(r.code, EXIT_NOT_ELEVATED);
        assert!(r.out.is_none(), "no code leaks on the refused path");
        assert!(r.msg.contains("authenticator app"));
        assert!(!r.save, "a refused fetch persists no mint");
    }

    // [unit->REQ-PAIR-6] an undeterminable elevation (Unknown) also falls back — the
    // same fail-safe path as NotElevated, never leaking the code.
    #[test]
    fn unknown_elevation_falls_back() {
        let mut store = store_with(&["home"]);
        let r = decide_show_totp(resolve(&mut store, None, None), 0, Elevation::Unknown);
        assert_eq!(r.code, EXIT_NOT_ELEVATED);
        assert!(r.out.is_none());
    }
}