Grep

fn cmd_pair_show_totp|fn run_listen|help_groups_cover_every_command|mod tests


526:/// headings); `help_groups_cover_every_command` keeps this set honest.
1346:fn run_listen(id: &str, once: bool, subnet: Option<&str>) -> i32 {
1560:fn cmd_pair_show_totp(subnet: Option<String>, create_new: Option<String>) -> i32 {
2690:mod tests {
2703:    fn help_groups_cover_every_command() {


1300	}
1301
1302	fn cmd_ring(target: String, from: Option<String>, timeout: u64) -> i32 {
1303	    let from = resolve_from(from);
1304	    if from.is_empty() {
1305	        eprintln!("NO_SELF: ring needs a sender id (--from <id> or $OWL_SESSION_ID)");
1306	        return 1;
1307	    }
1308	    let body = read_stdin();
1309	    let owlery = perch::owlery_dir();
1310
1311	    match ring::ring(&target, &from, &body, Duration::from_secs(timeout), &owlery) {
1312	        RingOutcome::Replied(reply) => {
1313	            print!("{}", reply);
1314	            let _ = std::io::stdout().flush();
1315	            0
1316	        }
1317	        RingOutcome::TimedOut => {
1318	            eprintln!("TIMEOUT:{} did not reply within {}s", target, timeout);
1319	            0
1320	        }
1321	        RingOutcome::Queued => {
1322	            eprintln!("QUEUED:{}", target);
1323	            0
1324	        }
1325	        RingOutcome::Delivered => {
1326	            eprintln!("SENT:{} (reply will arrive on your listener)", target);
1327	            0
1328	        }
1329	        RingOutcome::NoPerch => {
1330	            eprintln!("NO_PERCH:{} is not listening", target);
1331	            1
1332	        }
1333	        RingOutcome::Empty => {
1334	            eprintln!("EMPTY_MSG: refusing to ring {} with an empty message", target);
1335	            1
1336	        }
1337	    }
1338	}
1339
1340	/// Run the listener loop (REQ-MSG-3 lifecycle, driven from the CLI). Blocks
1341	/// until the process is killed unless `once` is set. Each received message is
1342	/// printed framed to stdout; status goes to stderr.
1343	///
1344	/// Interim M1 model: the perch is reachable only while this process runs. M3's
1345	/// spt-daemon decouples registration from the live process.
1346	fn run_listen(id: &str, once: bool, subnet: Option<&str>) -> i32 {
1347	    let (agent, backlog) = match ReadyAgent::start_homed(id, subnet) {
1348	        Ok(pair) => pair,
1349	        Err(e) => {
1350	            eprintln!("READY_FAIL:{}: {}", id, e);
1351	            return 1;
1352	        }
1353	    };
1354	    eprintln!("READY:{}", id);
1355	    for msg in backlog {
1356	        emit(&msg);
1357	    }
1358
1359	    if once {
1360	        if let Some(msg) = agent.recv() {
1361	            emit(&msg);
1362	        }
1363	        for msg in agent.drain_spool() {
1364	            emit(&msg);
1365	        }
1366	        return 0;
1367	    }
1368
1369	    loop {
1370	        if let Some(msg) = agent.recv() {
1371	            emit(&msg);
1372	        }
1373	        for msg in agent.drain_spool() {
1374	            emit(&msg);
1375	        }
1376	    }
1377	}
1378
1379	/// Print a framed message to stdout and flush (one delivery per emit).
1380	fn emit(msg: &str) {
1381	    print!("{}", msg);
1382	    let _ = std::io::stdout().flush();
1383	}
1384
1385	fn cmd_list() -> i32 {
1386	    let perches = roster::enumerate();
1387	    if perches.is_empty() {
1388	        eprintln!("(no perches)");
1389	        return 0;
1390	    }
1391	    for p in perches {
1392	        println!(
1393	            "{:<28} {:<14} {:<22} ready={} alive={}",
1394	            p.id,
1395	            p.state,
1396	            p.address.as_deref().unwrap_or("-"),
1397	            p.ready,
1398	            p.alive
1399	        );
1400	    }
1401	    0
1402	}
1403
1404	fn cmd_stop(id: &str) -> i32 {
1405	    let owlery = perch::owlery_dir();
1406	    // Soft-cleanup (HAZARD-SOFT-CLEANUP 6.2): remove the ready marker +
1407	    // unregister the address; spool.db and info.json are preserved.
1408	    let removed = std::fs::remove_file(perch::resolve_ready_file(id, ParentHint::Infer)).is_ok();
1409	    let _ = registry::unregister_address(id, &owlery);
1410	    if removed {
1411	        eprintln!("STOPPED:{}", id);
1412	    } else {
1413	        eprintln!("STOPPED:{} (no ready marker; address unregistered)", id);
1414	    }
1415	    0
1416	}
1417
1418	fn cmd_whoami() -> i32 {
1419	    match roster::detect_self_id() {
1420	        Some(id) => {
1421	            println!("{}", id);
1422	            0
1423	        }
1424	        None => {
1425	            eprintln!("UNKNOWN: no perch matches this session ($OWL_SESSION_ID / $SPT_AGENT_ID)");
1426	            1
1427	        }
1428	    }
1429	}
1430
1431	/// What `show-totp` resolved its arguments to against the node's subnet store — the
1432	/// platform-independent half of the command, decided before any OS elevation call so
1433	/// it can be unit-tested hermetically.
1434	enum Resolved {
1435	    /// A subnet to surface: its name + raw seed bytes. `minted` = freshly created by
1436	    /// `--create-new` (persist it only once the code is actually shown).
1437	    Subnet { name: String, seed: [u8; spt_store::subnet::TOTP_SEED_LEN], minted: bool },
1438	    /// Several subnets and no `--subnet` — refuse rather than guess.
1439	    Ambiguous(Vec<String>),
1440	    /// The node belongs to no subnet at all.
1441	    Empty,
1442	    /// A named subnet (`--subnet`) is not in the store.
1443	    NotFound(String),
1444	    /// `--create-new <name>` for a subnet that already exists.
1445	    Exists(String),
1446	    /// The stored seed is unreadable (corrupt hex/length) — re-pair to re-provision.
1447	    Corrupt(String),
1448	}
1449
1450	/// The rendered outcome of `show-totp`: an exit code, the optional stdout block (the
1451	/// code + URI — present ONLY on the elevated show path, never leaked otherwise), a
1452	/// stderr status line, and whether to persist a freshly-minted subnet. Pure over its
1453	/// inputs (no IO, no OS calls) so every branch is unit-tested.
1454	struct ShowResult {
1455	    code: i32,
1456	    out: Option<String>,
1457	    msg: String,
1458	    save: bool,
1459	}
1460
1461	/// Resolve `show-totp` arguments against the store (mutating it iff `--create-new`
1462	/// mints). The mint is in-memory only; the caller persists it just on the show path.
1463	// [impl->REQ-PAIR-3]
1464	fn resolve(store: &mut SubnetStore, subnet: Option<String>, create_new: Option<String>) -> Resolved {
1465	    if let Some(name) = create_new {
1466	        return match store.create_subnet(&name) {
1467	            Ok(rec) => match rec.seed_bytes() {
1468	                Some(seed) => Resolved::Subnet { name: rec.name.clone(), seed, minted: true },
1469	                None => Resolved::Corrupt(rec.name.clone()),
1470	            },
1471	            Err(SubnetError::Exists(n)) => Resolved::Exists(n),
1472	            Err(e) => Resolved::Corrupt(e.to_string()),
1473	        };
1474	    }
1475	    if let Some(name) = subnet {
1476	        return match store.find(&name) {
1477	            Some(rec) => match rec.seed_bytes() {
1478	                Some(seed) => Resolved::Subnet { name: rec.name.clone(), seed, minted: false },
1479	                None => Resolved::Corrupt(rec.name.clone()),
1480	            },
1481	            None => Resolved::NotFound(name),
1482	        };
1483	    }
1484	    // No flag: auto-select iff the node holds exactly one subnet (same no-guessing
1485	    // ethos as D3c addressing — several is an error, not a default pick).
1486	    match store.subnets.len() {
1487	        0 => Resolved::Empty,
1488	        1 => match store.subnets[0].seed_bytes() {
1489	            Some(seed) => Resolved::Subnet { name: store.subnets[0].name.clone(), seed, minted: false },
1490	            None => Resolved::Corrupt(store.subnets[0].name.clone()),
1491	        },
1492	        _ => Resolved::Ambiguous(store.subnets.iter().map(|s| s.name.clone()).collect()),
1493	    }
1494	}
1495
1496	/// Decide the rendered outcome from a resolution + the clock + the elevation level.
1497	/// The elevation gate is applied LAST: a resolved subnet still falls back (and leaks
1498	/// no code) unless the process is confirmed elevated.
1499	// [impl->REQ-PAIR-3] [impl->REQ-PAIR-6]
1500	fn decide_show_totp(resolved: Resolved, now: u64, elevation: Elevation) -> ShowResult {
1501	    match resolved {
1502	        Resolved::Subnet { name, seed, minted } => match elevation::gate_for(elevation) {
1503	            Gate::Show => {
1504	                let totp = TotpSeed::from_bytes(seed);
1505	                // The URI carries the base32 secret — importable into an authenticator
1506	                // app; QR *rendering* is GUI/later, so we print the URI text only.
1507	                let out = format!("{}\n{}\n", totp.code_at(now), totp.otpauth_uri(ISSUER, &name));
1508	                ShowResult { code: 0, out: Some(out), msg: format!("TOTP:{name} (elevated)"), save: minted }
1509	            }
1510	            // Refused fetch: no code, and we do NOT persist a `--create-new` mint —
1511	            // a refused command leaves no durable side effect (re-run elevated).
1512	            Gate::Fallback => ShowResult {
1513	                code: EXIT_NOT_ELEVATED,
1514	                out: None,
1515	                msg: elevation::fallback_message(&name),
1516	                save: false,
1517	            },
1518	        },
1519	        Resolved::Ambiguous(names) => ShowResult {
1520	            code: 2,
1521	            out: None,
1522	            msg: format!(
1523	                "AMBIGUOUS_SUBNET: this node holds {} subnets ({}) — pass --subnet <name>",
1524	                names.len(),
1525	                names.join(", ")
1526	            ),
1527	            save: false,
1528	        },
1529	        Resolved::Empty => ShowResult {
1530	            code: 2,
1531	            out: None,
1532	            msg: "NO_SUBNETS: this node belongs to no subnet — pair first, or mint one with --create-new <name>".to_string(),
1533	            save: false,
1534	        },
1535	        Resolved::NotFound(n) => ShowResult {
1536	            code: 2,
1537	            out: None,
1538	            msg: format!("NO_SUCH_SUBNET:{n}"),
1539	            save: false,
1540	        },
1541	        Resolved::Exists(n) => ShowResult {
1542	            code: 2,
1543	            out: None,
1544	            msg: format!("SUBNET_EXISTS:{n} — already a member; drop --create-new to show its code"),
1545	            save: false,
1546	        },
1547	        Resolved::Corrupt(n) => ShowResult {
1548	            code: 1,
1549	            out: None,
1550	            msg: format!("CORRUPT_SEED:{n} — stored seed is unreadable; re-pair to re-provision"),
1551	            save: false,
1552	        },
1553	    }
1554	}
1555
1556	/// `spt pair show-totp` — surface a subnet's current code off this node, behind the
1557	/// OS-elevation gate (REQ-PAIR-3/6). Wires the real store, clock, and elevation probe
1558	/// into the unit-tested [`resolve`]/[`decide_show_totp`] core.
1559	// [impl->REQ-PAIR-3] [impl->REQ-PAIR-6]
1560	fn cmd_pair_show_totp(subnet: Option<String>, create_new: Option<String>) -> i32 {
1561	    let mut store = SubnetStore::load();
1562	    let resolved = resolve(&mut store, subnet, create_new);
1563	    let result = decide_show_totp(resolved, now_unix(), elevation::current());
1564
1565	    if result.save {
1566	        if let Err(e) = store.save() {
1567	            eprintln!("SAVE_FAIL: freshly minted subnet not persisted: {e}");
1568	            return 1;
1569	        }
1570	        // First-join home adoption (REQ-INST-15): a fresh subnet on a
1571	        // previously-unpaired node homes every local-only Self perch.
1572	        let mut vis = spt_store::visibility::VisibilityStore::load();
1573	        let adopted =
1574	            spt_store::home::adopt_for_unset(&perch::owlery_dir(), &store, &mut vis);
1575	        if !adopted.is_empty() {
1576	            let _ = vis.save();
1577	            eprintln!("HOME_ADOPTED:{}", adopted.join(","));
1578	        }
1579	    }
1580	    if let Some(out) = result.out {
1581	        print!("{out}");
1582	        let _ = std::io::stdout().flush();
1583	    }
1584	    eprintln!("{}", result.msg);
1585	    result.code
1586	}
1587
1588	/// Current unix time in whole seconds (the TOTP clock input). A pre-epoch clock
1589	/// degrades to step 0 rather than panicking.
1590	/// `spt rename <old> <new>` — the on-disk rename ripple (REQ-INST-11). Validates
1591	/// the new id (the `:`/`@` reserved-delimiter rule — a qualified string can never
1592	/// become a bare id), then ripples the perch tree via
1593	/// [`spt_store::rename::rename_perch_in`]. Prints each rippled perch id.
1594	// [impl->REQ-INST-11]
1595	/// `spt access allow|revoke|open|list` — operate the endpoint access whitelist
1596	/// (`identity/trust/access.json`, ADR-0009). The daemon's gate re-reads the
1597	/// store per check, so edits take effect on the next inbound — no restart.
1598	// [impl->REQ-SEC-1]
1599	fn cmd_access(action: AccessCmd) -> i32 {
1600	    use spt_store::access::AccessStore;
1601
1602	    /// A node subject must be a 64-char Ed25519 pubkey hex — refuse typos
1603	    /// rather than store a subject that can never match (a silent lockout).
1604	    fn valid_node_hex(node: &str) -> bool {
1605	        node.len() == 64 && node.chars().all(|c| c.is_ascii_hexdigit())
1606	    }
1607	    fn save(store: &AccessStore) -> i32 {
1608	        match store.save() {
1609	            Ok(()) => 0,
1610	            Err(e) => {
1611	                eprintln!("ACCESS_FAIL: {e}");
1612	                1
1613	            }
1614	        }
1615	    }
1616
1617	    match action {
1618	        AccessCmd::Allow { endpoint, node } => {
1619	            let node = node.to_lowercase();
1620	            if !valid_node_hex(&node) {
1621	                eprintln!("ACCESS_BAD_NODE: {node:?} is not a 64-hex node pubkey");
1622	                return 1;
1623	            }
1624	            let mut s = AccessStore::load();
1625	            s.allow(&endpoint, &node);
1626	            let code = save(&s);
1627	            if code == 0 {
1628	                eprintln!("ACCESS_ALLOW:{endpoint}:{node} (endpoint is now restricted)");
1629	            }
1630	            code
1631	        }
1632	        AccessCmd::Revoke { endpoint, node } => {
1633	            let node = node.to_lowercase();
1634	            let mut s = AccessStore::load();
1635	            if !s.revoke(&endpoint, &node) {
1636	                eprintln!("ACCESS_NOT_LISTED:{endpoint}:{node} (nothing revoked)");
1637	                return 1;
1638	            }
1639	            let locked = s.lookup(&endpoint).map(|e| e.nodes.is_empty()).unwrap_or(false);
1640	            let code = save(&s);
1641	            if code == 0 {
1642	                eprintln!("ACCESS_REVOKE:{endpoint}:{node}");
1643	                if locked {
1644	                    eprintln!(
1645	                        "ACCESS_LOCKED:{endpoint} now refuses ALL unsolicited remote inbound; \
1646	                         `spt access open {endpoint}` returns it to default-open"
1647	                    );
1648	                }
1649	            }
1650	            code
1651	        }
1652	        AccessCmd::Open { endpoint } => {
1653	            let mut s = AccessStore::load();
1654	            if !s.open(&endpoint) {
1655	                eprintln!("ACCESS_ALREADY_OPEN:{endpoint} (no restriction existed)");
1656	                return 0;
1657	            }
1658	            let code = save(&s);
1659	            if code == 0 {
1660	                eprintln!("ACCESS_OPEN:{endpoint} (back to default-open)");
1661	            }
1662	            code
1663	        }
1664	        AccessCmd::List { endpoint } => {
1665	            let s = AccessStore::load();
1666	            match endpoint {
1667	                Some(ep) => match s.lookup(&ep) {
1668	                    None => println!("OPEN:{ep} (no restriction; any visible node may reach it)"),
1669	                    Some(e) if e.nodes.is_empty() => println!("LOCKED:{ep} (refuses all unsolicited remote inbound)"),
1670	                    Some(e) => println!("RESTRICTED:{ep}: {}", e.nodes.join(", ")),
1671	                },
1672	                None => {
1673	                    if s.entries.is_empty() {
1674	                        println!("no restrictions (every endpoint default-open)");
1675	                    }
1676	                    for e in &s.entries {
1677	                        if e.nodes.is_empty() {
1678	                            println!("LOCKED:{}", e.endpoint);
1679	                        } else {
1680	                            println!("RESTRICTED:{}: {}", e.endpoint, e.nodes.join(", "));
1681	                        }
1682	                    }
1683	                }
1684	            }
1685	            0
1686	        }
1687	    }
1688	}
1689
1690	/// `spt adapter add|remove|list` — the adapter registration lifecycle
1691	/// (REQ-INSTALL-4) over `spt_runtime::registry`. `add` is manifest-first
1692	/// (validate → record → conduct install-is-first-update through the declared
1693	/// `[update]` avenue); `remove` is a soft-deregister.
1694	// [impl->REQ-INSTALL-4]
1695	fn cmd_adapter(action: AdapterCmd) -> i32 {
1696	    use spt_runtime::registry;
1697	    use std::time::Duration;
1698
1699	    let adapters = spt_store::perch::adapters_dir();
1700	    let now_ms = std::time::SystemTime::now()
1701	        .duration_since(std::time::UNIX_EPOCH)
1702	        .map(|d| d.as_millis() as u64)
1703	        .unwrap_or(0);
1704
1705	    /// Conduct one bounded adapter template (first-update / uninstall) —
1706	    /// every subprocess gets a timeout (REQ-HAZARD-SUBPROCESS-TIMEOUT).
1707	    fn conduct(label: &str, template: &str) -> i32 {
1708	        match spt_runtime::run_bounded_command(
1709	            template,
1710	            &std::collections::BTreeMap::new(),
1711	            Duration::from_secs(300),
1712	            None,
1713	        ) {
1714	            Ok(out) if out.success() => {
1715	                eprintln!("ADAPTER_{label}_OK");
1716	                0
1717	            }
1718	            Ok(out) => {
1719	                eprintln!("ADAPTER_{label}_FAIL: exit {:?}", out.status_code);
1720	                1
1721	            }
1722	            Err(e) => {
1723	                eprintln!("ADAPTER_{label}_FAIL: {e}");
1724	                1
1725	            }
1726	        }
1727	    }
1728
1729	    match action {
1730	        AdapterCmd::Add { path, github } => {
1731	            let source = match (path, github) {
1732	                (Some(p), None) => std::path::PathBuf::from(p),
1733	                (None, Some(spec)) => {
1734	                    // Manifest-first from GitHub: shallow-clone under
1735	                    // adapters/_github/ (a durable home — pointer-mode records
1736	                    // may read their manifest from here for life).
1737	                    let safe = spec.replace(['/', '\\'], "-");
1738	                    let dest = adapters.join("_github").join(safe);
1739	                    if dest.exists() {
1740	                        let _ = std::fs::remove_dir_all(&dest);
1741	                    }
1742	                    if let Some(parent) = dest.parent() {
1743	                        let _ = std::fs::create_dir_all(parent);
1744	                    }
1745	                    // Bounded like every git subprocess
1746	                    // (REQ-HAZARD-SUBPROCESS-TIMEOUT).
1747	                    let keys = std::collections::BTreeMap::from([
1748	                        ("url".to_string(), format!("https://github.com/{spec}.git")),
1749	                        ("dest".to_string(), dest.to_string_lossy().into_owned()),

}

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, subnet: Option<&str>) -> i32 {
    let (agent, backlog) = match ReadyAgent::start_homed(id, subnet) {
        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;
        }
        // First-join home adoption (REQ-INST-15): a fresh subnet on a
        // previously-unpaired node homes every local-only Self perch.
        let mut vis = spt_store::visibility::VisibilityStore::load();
        let adopted =
            spt_store::home::adopt_for_unset(&perch::owlery_dir(), &store, &mut vis);
        if !adopted.is_empty() {
            let _ = vis.save();
            eprintln!("HOME_ADOPTED:{}", adopted.join(","));
        }
    }
    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
        }
    }
}

/// `spt adapter add|remove|list` — the adapter registration lifecycle
/// (REQ-INSTALL-4) over `spt_runtime::registry`. `add` is manifest-first
/// (validate → record → conduct install-is-first-update through the declared
/// `[update]` avenue); `remove` is a soft-deregister.
// [impl->REQ-INSTALL-4]
fn cmd_adapter(action: AdapterCmd) -> i32 {
    use spt_runtime::registry;
    use std::time::Duration;

    let adapters = spt_store::perch::adapters_dir();
    let now_ms = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0);

    /// Conduct one bounded adapter template (first-update / uninstall) —
    /// every subprocess gets a timeout (REQ-HAZARD-SUBPROCESS-TIMEOUT).
    fn conduct(label: &str, template: &str) -> i32 {
        match spt_runtime::run_bounded_command(
            template,
            &std::collections::BTreeMap::new(),
            Duration::from_secs(300),
            None,
        ) {
            Ok(out) if out.success() => {
                eprintln!("ADAPTER_{label}_OK");
                0
            }
            Ok(out) => {
                eprintln!("ADAPTER_{label}_FAIL: exit {:?}", out.status_code);
                1
            }
            Err(e) => {
                eprintln!("ADAPTER_{label}_FAIL: {e}");
                1
            }
        }
    }

    match action {
        AdapterCmd::Add { path, github } => {
            let source = match (path, github) {
                (Some(p), None) => std::path::PathBuf::from(p),
                (None, Some(spec)) => {
                    // Manifest-first from GitHub: shallow-clone under
                    // adapters/_github/ (a durable home — pointer-mode records
                    // may read their manifest from here for life).
                    let safe = spec.replace(['/', '\\'], "-");
                    let dest = adapters.join("_github").join(safe);
                    if dest.exists() {
                        let _ = std::fs::remove_dir_all(&dest);
                    }
                    if let Some(parent) = dest.parent() {
                        let _ = std::fs::create_dir_all(parent);
                    }
                    // Bounded like every git subprocess
                    // (REQ-HAZARD-SUBPROCESS-TIMEOUT).
                    let keys = std::collections::BTreeMap::from([
                        ("url".to_string(), format!("https://github.com/{spec}.git")),
                        ("dest".to_string(), dest.to_string_lossy().into_owned()),


2680	}
2681
2682	fn now_unix() -> u64 {
2683	    std::time::SystemTime::now()
2684	        .duration_since(std::time::UNIX_EPOCH)
2685	        .map(|d| d.as_secs())
2686	        .unwrap_or(0)
2687	}
2688
2689	#[cfg(test)]
2690	mod tests {
2691	    use super::*;
2692
2693	    fn parse(args: &[&str]) -> Result<Cli, clap::Error> {
2694	        Cli::try_parse_from(args)
2695	    }
2696
2697	    // [unit->REQ-MSG-2] the grouped --help contract holds: every visible
2698	    // command is classified (AGENT_COMMANDS names must exist), the rendered
2699	    // template carries both group headings, and every about stays a
2700	    // one-liner short enough that no list entry ever wraps on an 80-col
2701	    // terminal (the name column is 2+pad+2 wide).
2702	    #[test]
2703	    fn help_groups_cover_every_command() {
2704	        use clap::CommandFactory;
2705	        let mut cmd = Cli::command();
2706	        let template = grouped_help_template(&mut cmd);
2707	        assert!(template.contains("User commands:\n"));
2708	        assert!(template.contains("Agent commands:\n"));
2709
2710	        let pad =
2711	            cmd.get_subcommands().filter(|s| !s.is_hide_set()).map(|s| s.get_name().len()).max().unwrap();
2712	        for sc in cmd.get_subcommands().filter(|s| !s.is_hide_set()) {
2713	            let about = sc.get_about().unwrap_or_else(|| panic!("{} needs an about", sc.get_name())).to_string();
2714	            assert!(!about.contains('\n'), "{}: about must be a one-liner", sc.get_name());
2715	            assert!(
2716	                2 + pad + 2 + about.len() <= 80,
2717	                "{}: about too long for an unwrapped 80-col entry ({} chars)",
2718	                sc.get_name(),
2719	                about.len()
2720	            );
2721	            assert!(
2722	                template.contains(&format!("  {:<pad$}  {about}\n", sc.get_name())),
2723	                "{} missing from the rendered template",
2724	                sc.get_name()
2725	            );
2726	        }
2727	        for name in AGENT_COMMANDS {
2728	            assert!(cmd.find_subcommand(name).is_some(), "AGENT_COMMANDS names unknown command {name}");
2729	        }
2730	    }
2731
2732	    // [unit->REQ-MSG-2] the documented subcommands + arg shapes parse.
2733	    #[test]
2734	    fn subcommands_parse() {
2735	        assert!(matches!(parse(&["spt", "send", "bob", "--from", "alice"]).unwrap().cmd, Cmd::Send { .. }));
2736	        assert!(matches!(parse(&["spt", "send", "bob", "--deferred"]).unwrap().cmd, Cmd::Send { deferred: true, .. }));
2737	        assert!(matches!(parse(&["spt", "ring", "bob", "--timeout", "5"]).unwrap().cmd, Cmd::Ring { .. }));
2738	        assert!(matches!(parse(&["spt", "ready", "bob"]).unwrap().cmd, Cmd::Ready { .. }));
2739	        assert!(matches!(parse(&["spt", "poll", "bob", "--once"]).unwrap().cmd, Cmd::Poll { once: true, .. }));
2740	        assert!(matches!(parse(&["spt", "list"]).unwrap().cmd, Cmd::List));
2741	        assert!(matches!(parse(&["spt", "stop", "bob"]).unwrap().cmd, Cmd::Stop { .. }));
2742	        assert!(matches!(parse(&["spt", "whoami"]).unwrap().cmd, Cmd::Whoami));
2743	        // The hidden daemon entry point parses (auto-started by `api`).
2744	        assert!(matches!(parse(&["spt", "daemon"]).unwrap().cmd, Cmd::Daemon));
2745	        // [unit->REQ-INST-11] the rename surface parses (two positional ids).
2746	        assert!(matches!(parse(&["spt", "rename", "ling", "oak"]).unwrap().cmd, Cmd::Rename { .. }));
2747	        assert!(parse(&["spt", "rename", "ling"]).is_err(), "new id required");
2748	        // [unit->REQ-INST-15] the fork surface parses; the target is required.
2749	        assert!(matches!(
2750	            parse(&["spt", "fork", "ling", "ling-work", "--subnet", "work"]).unwrap().cmd,
2751	            Cmd::Fork { delete_source: false, .. }
2752	        ));
2753	        assert!(matches!(
2754	            parse(&["spt", "fork", "ling", "l2", "--subnet", "work", "--delete-source"])
2755	                .unwrap()
2756	                .cmd,
2757	            Cmd::Fork { delete_source: true, .. }
2758	        ));
2759	        assert!(parse(&["spt", "fork", "ling", "l2"]).is_err(), "--subnet required");
2760	        assert!(parse(&["spt", "fork", "ling"]).is_err(), "new id required");
2761	        // [unit->REQ-INST-14] the resources surfaces parse.
2762	        assert!(matches!(
2763	            parse(&["spt", "resources", "set", "rust builds", "--id", "ling"]).unwrap().cmd,
2764	            Cmd::Resources { action: ResourcesCmd::Set { .. } }
2765	        ));
2766	        assert!(matches!(
2767	            parse(&["spt", "resources", "show"]).unwrap().cmd,
2768	            Cmd::Resources { action: ResourcesCmd::Show { id: None } }
2769	        ));
2770	        assert!(matches!(
2771	            parse(&["spt", "resources", "list", "--subnet", "home"]).unwrap().cmd,
2772	            Cmd::Resources { action: ResourcesCmd::List { subnet: Some(_) } }
2773	        ));
2774	        assert!(parse(&["spt", "resources", "set"]).is_err(), "blurb text required");
2775	        // [unit->REQ-INST-3] the resting-command surfaces parse (one local id).
2776	        assert!(matches!(parse(&["spt", "suspend", "ling"]).unwrap().cmd, Cmd::Suspend { .. }));
2777	        assert!(matches!(parse(&["spt", "wake", "ling"]).unwrap().cmd, Cmd::Wake { .. }));
2778	        // [unit->REQ-SHELL-2] shutdown parses with and without the self-default id.
2779	        assert!(matches!(parse(&["spt", "shutdown"]).unwrap().cmd, Cmd::Shutdown { id: None }));
2780	        assert!(matches!(
2781	            parse(&["spt", "shutdown", "ling"]).unwrap().cmd,
2782	            Cmd::Shutdown { id: Some(_) }
2783	        ));
2784	        assert!(parse(&["spt", "suspend"]).is_err(), "id required");
2785	        assert!(parse(&["spt", "wake"]).is_err(), "id required");
2786	        // The digest surface + its flags.
2787	        assert!(matches!(parse(&["spt", "digest", "doyle"]).unwrap().cmd, Cmd::Digest { .. }));
2788	        assert!(matches!(
2789	            parse(&["spt", "digest", "doyle", "--follow", "--json"]).unwrap().cmd,
2790	            Cmd::Digest { follow: true, json: true, .. }
2791	        ));
2792	    }
2793
2794	    // [unit->REQ-NOTIF-2] the notify/notif surfaces parse: notify takes an
2795	    // optional body + --subnet/--from; notif list/dismiss shapes hold.
2796	    #[test]
2797	    fn notify_surface_parses() {
2798	        assert!(matches!(
2799	            parse(&["spt", "notify", "build done", "--subnet", "home", "--from", "doyle"])
2800	                .unwrap()
2801	                .cmd,
2802	            Cmd::Notify { .. }
2803	        ));
2804	        assert!(matches!(parse(&["spt", "notify"]).unwrap().cmd, Cmd::Notify { body: None, .. }));
2805	        assert!(matches!(
2806	            parse(&["spt", "notif", "list", "--subnet", "home"]).unwrap().cmd,
2807	            Cmd::Notif { action: NotifCmd::List { .. } }
2808	        ));
2809	        assert!(matches!(
2810	            parse(&["spt", "notif", "dismiss", "cafe:7"]).unwrap().cmd,
2811	            Cmd::Notif { action: NotifCmd::Dismiss { .. } }
2812	        ));
2813	        assert!(parse(&["spt", "notif", "dismiss"]).is_err(), "notif id required");
2814	        // [unit->REQ-UPD-4] the update-consent body names this explicit ack verb.
2815	        assert!(matches!(
2816	            parse(&["spt", "update", "apply"]).unwrap().cmd,
2817	            Cmd::Update { action: UpdateCmd::Apply }
2818	        ));
2819	    }
2820
2821	    // [unit->REQ-NOTIF-2] subnet resolution refuses to guess: explicit
2822	    // non-member refused; zero subnets refused; exactly one auto-selects;
2823	    // several force --subnet.
2824	    #[test]
2825	    fn notif_subnet_resolution_never_guesses() {
2826	        let mut store = SubnetStore::default();
2827	        assert_eq!(resolve_notif_subnet(&store, None), Err(1), "no subnet => refuse");
2828	        assert_eq!(
2829	            resolve_notif_subnet(&store, Some("home".into())),
2830	            Err(1),
2831	            "non-member => refuse"
2832	        );
2833
2834	        store.create_subnet("home").unwrap();
2835	        assert_eq!(resolve_notif_subnet(&store, None), Ok("home".to_string()), "sole subnet");
2836	        assert_eq!(resolve_notif_subnet(&store, Some("home".into())), Ok("home".to_string()));
2837
2838	        store.create_subnet("work").unwrap();
2839	        assert_eq!(resolve_notif_subnet(&store, None), Err(1), "several => qualify");
2840	        assert_eq!(resolve_notif_subnet(&store, Some("work".into())), Ok("work".to_string()));
2841	    }
2842
2843	    // [unit->REQ-SEC-1] the access whitelist surface parses: allow/revoke take
2844	    // (endpoint, node); open takes the endpoint; list takes an optional one.
2845	    #[test]
2846	    fn access_subcommands_parse() {
2847	        assert!(matches!(
2848	            parse(&["spt", "access", "allow", "ling", "aa11"]).unwrap().cmd,
2849	            Cmd::Access { action: AccessCmd::Allow { .. } }
2850	        ));
2851	        assert!(matches!(
2852	            parse(&["spt", "access", "revoke", "ling", "aa11"]).unwrap().cmd,
2853	            Cmd::Access { action: AccessCmd::Revoke { .. } }
2854	        ));
2855	        assert!(matches!(
2856	            parse(&["spt", "access", "open", "ling"]).unwrap().cmd,
2857	            Cmd::Access { action: AccessCmd::Open { .. } }
2858	        ));
2859	        assert!(matches!(
2860	            parse(&["spt", "access", "list"]).unwrap().cmd,
2861	            Cmd::Access { action: AccessCmd::List { endpoint: None } }
2862	        ));
2863	        assert!(matches!(
2864	            parse(&["spt", "access", "list", "ling"]).unwrap().cmd,
2865	            Cmd::Access { action: AccessCmd::List { endpoint: Some(_) } }
2866	        ));
2867	        assert!(parse(&["spt", "access", "allow", "ling"]).is_err(), "node required");
2868	    }
2869
2870	    // [unit->REQ-CONSENT-1] the grant surface parses: add/revoke take
2871	    // capability + agent with an optional --qualifier; list takes an
2872	    // optional agent filter.
2873	    #[test]
2874	    fn grant_subcommands_parse() {
2875	        match parse(&["spt", "grant", "add", "spawn-shell", "ling", "--qualifier", "OsNotify"])
2876	            .unwrap()
2877	            .cmd
2878	        {
2879	            Cmd::Grant { action: GrantCmd::Add { capability, agent, qualifier } } => {
2880	                assert_eq!(capability, "spawn-shell");
2881	                assert_eq!(agent, "ling");
2882	                assert_eq!(qualifier.as_deref(), Some("OsNotify"));
2883	            }
2884	            _ => panic!("expected Grant Add"),
2885	        }
2886	        assert!(matches!(
2887	            parse(&["spt", "grant", "add", "owner-shutdown", "ling"]).unwrap().cmd,
2888	            Cmd::Grant { action: GrantCmd::Add { qualifier: None, .. } }
2889	        ));
2890	        assert!(matches!(
2891	            parse(&["spt", "grant", "revoke", "spawn-shell", "ling"]).unwrap().cmd,
2892	            Cmd::Grant { action: GrantCmd::Revoke { .. } }
2893	        ));
2894	        assert!(matches!(
2895	            parse(&["spt", "grant", "list"]).unwrap().cmd,
2896	            Cmd::Grant { action: GrantCmd::List { agent: None } }
2897	        ));
2898	        assert!(matches!(
2899	            parse(&["spt", "grant", "list", "ling"]).unwrap().cmd,
2900	            Cmd::Grant { action: GrantCmd::List { agent: Some(_) } }
2901	        ));
2902	        assert!(parse(&["spt", "grant", "add", "spawn-shell"]).is_err(), "agent required");
2903	    }
2904
2905	    // [unit->REQ-INSTALL-4] the adapter surface parses: add takes a path or
2906	    // --github; remove takes a name + --force; list stands alone.
2907	    #[test]
2908	    fn adapter_subcommands_parse() {
2909	        assert!(matches!(
2910	            parse(&["spt", "adapter", "add", "C:/adapters/mock"]).unwrap().cmd,
2911	            Cmd::Adapter { action: AdapterCmd::Add { path: Some(_), github: None } }
2912	        ));
2913	        assert!(matches!(
2914	            parse(&["spt", "adapter", "add", "--github", "user/repo"]).unwrap().cmd,
2915	            Cmd::Adapter { action: AdapterCmd::Add { path: None, github: Some(_) } }
2916	        ));
2917	        match parse(&["spt", "adapter", "remove", "mock-shell", "--force"]).unwrap().cmd {
2918	            Cmd::Adapter { action: AdapterCmd::Remove { name, force } } => {
2919	                assert_eq!(name, "mock-shell");
2920	                assert!(force);
2921	            }
2922	            _ => panic!("expected Adapter Remove"),
2923	        }
2924	        assert!(matches!(
2925	            parse(&["spt", "adapter", "list"]).unwrap().cmd,
2926	            Cmd::Adapter { action: AdapterCmd::List }
2927	        ));
2928	    }
2929
2930	    // [unit->REQ-EP-5] the shell surface parses: spawn takes an adapter +
2931	    // --alias/--owner; list/teardown/rename/cmd/relink shapes hold.
2932	    #[test]
2933	    fn shell_subcommands_parse() {
2934	        match parse(&["spt", "shell", "spawn", "GameRobot", "--alias", "TempleKeeper", "--owner", "doyle"])
2935	            .unwrap()
2936	            .cmd
2937	        {
2938	            Cmd::Shell { action: ShellCmd::Spawn { adapter, alias, owner } } => {
2939	                assert_eq!(adapter, "GameRobot");
2940	                assert_eq!(alias.as_deref(), Some("TempleKeeper"));
2941	                assert_eq!(owner.as_deref(), Some("doyle"));
2942	            }
2943	            _ => panic!("expected Shell Spawn"),
2944	        }
2945	        assert!(matches!(
2946	            parse(&["spt", "shell", "spawn", "GameRobot"]).unwrap().cmd,
2947	            Cmd::Shell { action: ShellCmd::Spawn { alias: None, owner: None, .. } }
2948	        ));
2949	        assert!(matches!(
2950	            parse(&["spt", "shell", "list"]).unwrap().cmd,
2951	            Cmd::Shell { action: ShellCmd::List { owner: None } }
2952	        ));
2953	        assert!(matches!(
2954	            parse(&["spt", "shell", "teardown", "TempleKeeper"]).unwrap().cmd,
2955	            Cmd::Shell { action: ShellCmd::Teardown { .. } }
2956	        ));
2957	        assert!(matches!(
2958	            parse(&["spt", "shell", "rename", "GameRobot-0", "Keeper"]).unwrap().cmd,
2959	            Cmd::Shell { action: ShellCmd::Rename { .. } }
2960	        ));
2961	        match parse(&["spt", "shell", "cmd", "TempleKeeper", "move", "north", "3"]).unwrap().cmd {
2962	            Cmd::Shell { action: ShellCmd::Cmd { shell_ref, op, .. } } => {
2963	                assert_eq!(shell_ref, "TempleKeeper");
2964	                assert_eq!(op, vec!["move", "north", "3"]);
2965	            }
2966	            _ => panic!("expected Shell Cmd"),
2967	        }
2968	        assert!(matches!(
2969	            parse(&["spt", "shell", "relink", "GameRobot-0"]).unwrap().cmd,
2970	            Cmd::Shell { action: ShellCmd::Relink { .. } }
2971	        ));
2972	        match parse(&["spt", "shell", "send", "Scout", "hello", "--file", "map.png"]).unwrap().cmd {
2973	            Cmd::Shell { action: ShellCmd::Send { shell_ref, text, file, .. } } => {
2974	                assert_eq!(shell_ref, "Scout");
2975	                assert_eq!(text.as_deref(), Some("hello"));
2976	                assert_eq!(file.unwrap(), PathBuf::from("map.png"));
2977	            }
2978	            _ => panic!("expected Shell Send"),
2979	        }
2980	        assert!(matches!(
2981	            parse(&["spt", "shell", "send", "Scout"]).unwrap().cmd,
2982	            Cmd::Shell { action: ShellCmd::Send { text: None, file: None, .. } }
2983	        ));
2984	        assert!(parse(&["spt", "shell", "spawn"]).is_err(), "adapter required");
2985	        assert!(parse(&["spt", "shell", "rename", "GameRobot-0"]).is_err(), "alias required");
2986	    }
2987
2988	    // [unit->REQ-EP-5] spawn end to end on one node: refuses an unregistered
2989	    // adapter and a harness adapter; mints sequentially for a registered
2990	    // shell adapter; a deregistered adapter stops minting (existing
2991	    // instances keep their perches); teardown frees the slot; an invalid
2992	    // alias is a usage error.
2993	    #[test]
2994	    fn shell_spawn_gates_on_the_registered_set() {
2995	        let _h = crate::testutil::isolated_home();
2996	        let adapters = spt_store::perch::adapters_dir();
2997
2998	        let spawn = |adapter: &str, alias: Option<&str>| {
2999	            cmd_shell(ShellCmd::Spawn {
3000	                adapter: adapter.into(),
3001	                alias: alias.map(str::to_string),
3002	                owner: Some("doyle".into()),
3003	            })
3004	        };
3005
3006	        // Nothing registered → refuse.
3007	        assert_eq!(spawn("mock-shell", None), 1);
3008
3009	        // Register a harness and a shell adapter. The shell's spawn template
3010	        // is a cross-platform no-op so the D3b broker-launch succeeds.
3011	        #[cfg(windows)]
3012	        let noop = "cmd /c exit 0";
3013	        #[cfg(unix)]
3014	        let noop = "true";
3015	        let shell_toml = format!(
3016	            "[adapter]\nname = \"mock-shell\"\nkind = \"shell\"\nversion = \"1\"\nmin_spt_core_version = \"0\"\n\n[shell]\nspawn = \"{noop} --link {{link_token}}\"\n"
3017	        );
3018	        let src = spt_store::perch::spt_home().join("srcs");
3019	        for (name, toml_src) in [
3020	            (
3021	                "h-src",
3022	                "[adapter]\nname = \"mock-harness\"\nkind = \"harness\"\nversion = \"1\"\nmin_spt_core_version = \"0\"\nhostable_types = [\"LiveAgent\"]\n",
3023	            ),
3024	            ("s-src", shell_toml.as_str()),
3025	        ] {
3026	            let d = src.join(name);
3027	            std::fs::create_dir_all(&d).unwrap();
3028	            std::fs::write(d.join("manifest.toml"), toml_src).unwrap();
3029	            spt_runtime::registry::register(&adapters, &d, 1000).unwrap();
3030	        }
3031
3032	        // A harness adapter is not spawnable as a shell.
3033	        assert_eq!(spawn("mock-harness", None), 1);
3034
3035	        // The owner is a bound Self perch (the bind-by-token scan starts there).
3036	        let owner_perch = perch::resolve_perch_path("doyle", ParentHint::Infer);
3037	        std::fs::create_dir_all(&owner_perch).unwrap();
3038	        std::fs::write(owner_perch.join("info.json"), "{}").unwrap();
3039
3040	        // The registered shell adapter mints sequentially…
3041	        assert_eq!(spawn("mock-shell", Some("Toast")), 0);
3042	        assert_eq!(spawn("mock-shell", None), 0);
3043	        let owlery = perch::owlery_dir();
3044	        let listed = spt_store::shellinfo::list_shells(&owlery, "doyle");
3045	        assert_eq!(
3046	            listed.iter().map(|(id, _)| id.as_str()).collect::<Vec<_>>(),
3047	            vec!["mock-shell-0", "mock-shell-1"]
3048	        );
3049	        // …with the alias resolving to the same instance as the canonical id.
3050	        assert_eq!(
3051	            spt_store::shellinfo::resolve_shell_ref(&owlery, "doyle", "Toast").unwrap().0,
3052	            "mock-shell-0"
3053	        );
3054	        // A duplicate alias is a runtime refusal; an invalid one a usage error.
3055	        assert_eq!(spawn("mock-shell", Some("Toast")), 1);
3056	        assert_eq!(spawn("mock-shell", Some("bad:alias")), 2);
3057
3058	        // [unit->REQ-SHELL-1] D3b handshake through the api surface: launch
3059	        // parked a link token (perch offline); `api bind-shell --link` flips
3060	        // it online; a bogus token is refused.
3061	        let shell_perch = perch::resolve_shell_perch_path_in(&owlery, "doyle", "mock-shell-0");
3062	        let token = spt_daemon::shellhost::read_link_token(&shell_perch).expect("token parked");
3063	        let bind = |tok: &str| match parse(&["spt", "api", "--adapter", "mock", "bind-shell", "--link", tok])
3064	            .unwrap()
3065	            .cmd
3066	        {
3067	            Cmd::Api(args) => crate::api::run(args),
3068	            _ => panic!("expected Api"),
3069	        };
3070	        assert_eq!(bind("00000000000000000000000000000000"), 1, "bogus token refused");
3071	        assert_eq!(
3072	            spt_store::shellinfo::read_shell_info(&shell_perch).unwrap().status,
3073	            spt_store::shellinfo::SHELL_STATUS_OFFLINE
3074	        );
3075	        assert_eq!(bind(&token), 0);
3076	        assert_eq!(
3077	            spt_store::shellinfo::read_shell_info(&shell_perch).unwrap().status,
3078	            spt_store::shellinfo::SHELL_STATUS_ONLINE
3079	        );
3080
3081	        // Teardown frees the slot…
3082	        assert_eq!(
3083	            cmd_shell(ShellCmd::Teardown { shell_ref: "Toast".into(), owner: Some("doyle".into()) }),
3084	            0
3085	        );
3086	        assert_eq!(spawn("mock-shell", None), 0);
3087	        assert!(spt_store::shellinfo::resolve_shell_ref(&owlery, "doyle", "mock-shell-0").is_some());
3088
3089	        // …and a deregistered adapter stops minting while existing perches stay.
3090	        spt_runtime::registry::deregister(&adapters, "mock-shell").unwrap();
3091	        assert_eq!(spawn("mock-shell", None), 1);
3092	        assert_eq!(spt_store::shellinfo::list_shells(&owlery, "doyle").len(), 2);
3093
3094	        // A deregistered adapter's instances can't be driven (capabilities
3095	        // resolve from the registered manifest, never the perch) — and the D4
3096	        // surface still refuses.
3097	        assert_eq!(
3098	            cmd_shell(ShellCmd::Cmd {
3099	                shell_ref: "mock-shell-0".into(),
3100	                op: vec!["notify".into()],
3101	                owner: Some("doyle".into()),
3102	            }),
3103	            1
3104	        );
3105	        assert_eq!(
3106	            cmd_shell(ShellCmd::Relink { shell_ref: "mock-shell-0".into(), owner: Some("doyle".into()) }),
3107	            1
3108	        );
3109	    }
3110
3111	    // [unit->REQ-SHELL-2] relink is the manual online switch (M5-D4a):
3112	    // offline-only (an online instance refuses), owner-scoped (a foreign ref
3113	    // resolves nothing), re-launches with a FRESH link token (the old one
3114	    // died with the close), and is not the online flip itself — the perch
3115	    // onlines at the binary's bind, same as spawn.
3116	    #[test]
3117	    fn shell_relink_relaunches_offline_instances_only() {
3118	        let _h = crate::testutil::isolated_home();
3119	        let adapters = spt_store::perch::adapters_dir();
3120	        let owlery = perch::owlery_dir();
3121
3122	        #[cfg(windows)]
3123	        let noop = "cmd /c exit 0";
3124	        #[cfg(unix)]
3125	        let noop = "true";
3126	        let toml_src = format!(
3127	            "[adapter]\nname = \"mock-shell\"\nkind = \"shell\"\nversion = \"1\"\n\
3128	             min_spt_core_version = \"0\"\n\n[shell]\nspawn = \"{noop} --link {{link_token}}\"\n"
3129	        );

}

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 grouped --help contract holds: every visible
    // command is classified (AGENT_COMMANDS names must exist), the rendered
    // template carries both group headings, and every about stays a
    // one-liner short enough that no list entry ever wraps on an 80-col
    // terminal (the name column is 2+pad+2 wide).
    #[test]
    fn help_groups_cover_every_command() {
        use clap::CommandFactory;
        let mut cmd = Cli::command();
        let template = grouped_help_template(&mut cmd);
        assert!(template.contains("User commands:\n"));
        assert!(template.contains("Agent commands:\n"));

        let pad =
            cmd.get_subcommands().filter(|s| !s.is_hide_set()).map(|s| s.get_name().len()).max().unwrap();
        for sc in cmd.get_subcommands().filter(|s| !s.is_hide_set()) {
            let about = sc.get_about().unwrap_or_else(|| panic!("{} needs an about", sc.get_name())).to_string();
            assert!(!about.contains('\n'), "{}: about must be a one-liner", sc.get_name());
            assert!(
                2 + pad + 2 + about.len() <= 80,
                "{}: about too long for an unwrapped 80-col entry ({} chars)",
                sc.get_name(),
                about.len()
            );
            assert!(
                template.contains(&format!("  {:<pad$}  {about}\n", sc.get_name())),
                "{} missing from the rendered template",
                sc.get_name()
            );
        }
        for name in AGENT_COMMANDS {
            assert!(cmd.find_subcommand(name).is_some(), "AGENT_COMMANDS names unknown command {name}");
        }
    }

    // [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");
        // [unit->REQ-INST-15] the fork surface parses; the target is required.
        assert!(matches!(
            parse(&["spt", "fork", "ling", "ling-work", "--subnet", "work"]).unwrap().cmd,
            Cmd::Fork { delete_source: false, .. }
        ));
        assert!(matches!(
            parse(&["spt", "fork", "ling", "l2", "--subnet", "work", "--delete-source"])
                .unwrap()
                .cmd,
            Cmd::Fork { delete_source: true, .. }
        ));
        assert!(parse(&["spt", "fork", "ling", "l2"]).is_err(), "--subnet required");
        assert!(parse(&["spt", "fork", "ling"]).is_err(), "new id required");
        // [unit->REQ-INST-14] the resources surfaces parse.
        assert!(matches!(
            parse(&["spt", "resources", "set", "rust builds", "--id", "ling"]).unwrap().cmd,
            Cmd::Resources { action: ResourcesCmd::Set { .. } }
        ));
        assert!(matches!(
            parse(&["spt", "resources", "show"]).unwrap().cmd,
            Cmd::Resources { action: ResourcesCmd::Show { id: None } }
        ));
        assert!(matches!(
            parse(&["spt", "resources", "list", "--subnet", "home"]).unwrap().cmd,
            Cmd::Resources { action: ResourcesCmd::List { subnet: Some(_) } }
        ));
        assert!(parse(&["spt", "resources", "set"]).is_err(), "blurb text required");
        // [unit->REQ-INST-3] the resting-command surfaces parse (one local id).
        assert!(matches!(parse(&["spt", "suspend", "ling"]).unwrap().cmd, Cmd::Suspend { .. }));
        assert!(matches!(parse(&["spt", "wake", "ling"]).unwrap().cmd, Cmd::Wake { .. }));
        // [unit->REQ-SHELL-2] shutdown parses with and without the self-default id.
        assert!(matches!(parse(&["spt", "shutdown"]).unwrap().cmd, Cmd::Shutdown { id: None }));
        assert!(matches!(
            parse(&["spt", "shutdown", "ling"]).unwrap().cmd,
            Cmd::Shutdown { id: Some(_) }
        ));
        assert!(parse(&["spt", "suspend"]).is_err(), "id required");
        assert!(parse(&["spt", "wake"]).is_err(), "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-UPD-4] the update-consent body names this explicit ack verb.
        assert!(matches!(
            parse(&["spt", "update", "apply"]).unwrap().cmd,
            Cmd::Update { action: UpdateCmd::Apply }
        ));
    }

    // [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-CONSENT-1] the grant surface parses: add/revoke take
    // capability + agent with an optional --qualifier; list takes an
    // optional agent filter.
    #[test]
    fn grant_subcommands_parse() {
        match parse(&["spt", "grant", "add", "spawn-shell", "ling", "--qualifier", "OsNotify"])
            .unwrap()
            .cmd
        {
            Cmd::Grant { action: GrantCmd::Add { capability, agent, qualifier } } => {
                assert_eq!(capability, "spawn-shell");
                assert_eq!(agent, "ling");
                assert_eq!(qualifier.as_deref(), Some("OsNotify"));
            }
            _ => panic!("expected Grant Add"),
        }
        assert!(matches!(
            parse(&["spt", "grant", "add", "owner-shutdown", "ling"]).unwrap().cmd,
            Cmd::Grant { action: GrantCmd::Add { qualifier: None, .. } }
        ));
        assert!(matches!(
            parse(&["spt", "grant", "revoke", "spawn-shell", "ling"]).unwrap().cmd,
            Cmd::Grant { action: GrantCmd::Revoke { .. } }
        ));
        assert!(matches!(
            parse(&["spt", "grant", "list"]).unwrap().cmd,
            Cmd::Grant { action: GrantCmd::List { agent: None } }
        ));
        assert!(matches!(
            parse(&["spt", "grant", "list", "ling"]).unwrap().cmd,
            Cmd::Grant { action: GrantCmd::List { agent: Some(_) } }
        ));
        assert!(parse(&["spt", "grant", "add", "spawn-shell"]).is_err(), "agent required");
    }

    // [unit->REQ-INSTALL-4] the adapter surface parses: add takes a path or
    // --github; remove takes a name + --force; list stands alone.
    #[test]
    fn adapter_subcommands_parse() {
        assert!(matches!(
            parse(&["spt", "adapter", "add", "C:/adapters/mock"]).unwrap().cmd,
            Cmd::Adapter { action: AdapterCmd::Add { path: Some(_), github: None } }
        ));
        assert!(matches!(
            parse(&["spt", "adapter", "add", "--github", "user/repo"]).unwrap().cmd,
            Cmd::Adapter { action: AdapterCmd::Add { path: None, github: Some(_) } }
        ));
        match parse(&["spt", "adapter", "remove", "mock-shell", "--force"]).unwrap().cmd {
            Cmd::Adapter { action: AdapterCmd::Remove { name, force } } => {
                assert_eq!(name, "mock-shell");
                assert!(force);
            }
            _ => panic!("expected Adapter Remove"),
        }
        assert!(matches!(
            parse(&["spt", "adapter", "list"]).unwrap().cmd,
            Cmd::Adapter { action: AdapterCmd::List }
        ));
    }

    // [unit->REQ-EP-5] the shell surface parses: spawn takes an adapter +
    // --alias/--owner; list/teardown/rename/cmd/relink shapes hold.
    #[test]
    fn shell_subcommands_parse() {
        match parse(&["spt", "shell", "spawn", "GameRobot", "--alias", "TempleKeeper", "--owner", "doyle"])
            .unwrap()
            .cmd
        {
            Cmd::Shell { action: ShellCmd::Spawn { adapter, alias, owner } } => {
                assert_eq!(adapter, "GameRobot");
                assert_eq!(alias.as_deref(), Some("TempleKeeper"));
                assert_eq!(owner.as_deref(), Some("doyle"));
            }
            _ => panic!("expected Shell Spawn"),
        }
        assert!(matches!(
            parse(&["spt", "shell", "spawn", "GameRobot"]).unwrap().cmd,
            Cmd::Shell { action: ShellCmd::Spawn { alias: None, owner: None, .. } }
        ));
        assert!(matches!(
            parse(&["spt", "shell", "list"]).unwrap().cmd,
            Cmd::Shell { action: ShellCmd::List { owner: None } }
        ));
        assert!(matches!(
            parse(&["spt", "shell", "teardown", "TempleKeeper"]).unwrap().cmd,
            Cmd::Shell { action: ShellCmd::Teardown { .. } }
        ));
        assert!(matches!(
            parse(&["spt", "shell", "rename", "GameRobot-0", "Keeper"]).unwrap().cmd,
            Cmd::Shell { action: ShellCmd::Rename { .. } }
        ));
        match parse(&["spt", "shell", "cmd", "TempleKeeper", "move", "north", "3"]).unwrap().cmd {
            Cmd::Shell { action: ShellCmd::Cmd { shell_ref, op, .. } } => {
                assert_eq!(shell_ref, "TempleKeeper");
                assert_eq!(op, vec!["move", "north", "3"]);
            }
            _ => panic!("expected Shell Cmd"),
        }
        assert!(matches!(
            parse(&["spt", "shell", "relink", "GameRobot-0"]).unwrap().cmd,
            Cmd::Shell { action: ShellCmd::Relink { .. } }
        ));
        match parse(&["spt", "shell", "send", "Scout", "hello", "--file", "map.png"]).unwrap().cmd {
            Cmd::Shell { action: ShellCmd::Send { shell_ref, text, file, .. } } => {
                assert_eq!(shell_ref, "Scout");
                assert_eq!(text.as_deref(), Some("hello"));
                assert_eq!(file.unwrap(), PathBuf::from("map.png"));
            }
            _ => panic!("expected Shell Send"),
        }
        assert!(matches!(
            parse(&["spt", "shell", "send", "Scout"]).unwrap().cmd,
            Cmd::Shell { action: ShellCmd::Send { text: None, file: None, .. } }
        ));
        assert!(parse(&["spt", "shell", "spawn"]).is_err(), "adapter required");
        assert!(parse(&["spt", "shell", "rename", "GameRobot-0"]).is_err(), "alias required");
    }

    // [unit->REQ-EP-5] spawn end to end on one node: refuses an unregistered
    // adapter and a harness adapter; mints sequentially for a registered
    // shell adapter; a deregistered adapter stops minting (existing
    // instances keep their perches); teardown frees the slot; an invalid
    // alias is a usage error.
    #[test]
    fn shell_spawn_gates_on_the_registered_set() {
        let _h = crate::testutil::isolated_home();
        let adapters = spt_store::perch::adapters_dir();

        let spawn = |adapter: &str, alias: Option<&str>| {
            cmd_shell(ShellCmd::Spawn {
                adapter: adapter.into(),
                alias: alias.map(str::to_string),
                owner: Some("doyle".into()),
            })
        };

        // Nothing registered → refuse.
        assert_eq!(spawn("mock-shell", None), 1);

        // Register a harness and a shell adapter. The shell's spawn template
        // is a cross-platform no-op so the D3b broker-launch succeeds.
        #[cfg(windows)]
        let noop = "cmd /c exit 0";
        #[cfg(unix)]
        let noop = "true";
        let shell_toml = format!(
            "[adapter]\nname = \"mock-shell\"\nkind = \"shell\"\nversion = \"1\"\nmin_spt_core_version = \"0\"\n\n[shell]\nspawn = \"{noop} --link {{link_token}}\"\n"
        );
        let src = spt_store::perch::spt_home().join("srcs");
        for (name, toml_src) in [
            (
                "h-src",
                "[adapter]\nname = \"mock-harness\"\nkind = \"harness\"\nversion = \"1\"\nmin_spt_core_version = \"0\"\nhostable_types = [\"LiveAgent\"]\n",
            ),
            ("s-src", shell_toml.as_str()),
        ] {
            let d = src.join(name);
            std::fs::create_dir_all(&d).unwrap();
            std::fs::write(d.join("manifest.toml"), toml_src).unwrap();
            spt_runtime::registry::register(&adapters, &d, 1000).unwrap();
        }

        // A harness adapter is not spawnable as a shell.
        assert_eq!(spawn("mock-harness", None), 1);

        // The owner is a bound Self perch (the bind-by-token scan starts there).
        let owner_perch = perch::resolve_perch_path("doyle", ParentHint::Infer);
        std::fs::create_dir_all(&owner_perch).unwrap();
        std::fs::write(owner_perch.join("info.json"), "{}").unwrap();

        // The registered shell adapter mints sequentially…
        assert_eq!(spawn("mock-shell", Some("Toast")), 0);
        assert_eq!(spawn("mock-shell", None), 0);
        let owlery = perch::owlery_dir();
        let listed = spt_store::shellinfo::list_shells(&owlery, "doyle");
        assert_eq!(
            listed.iter().map(|(id, _)| id.as_str()).collect::<Vec<_>>(),
            vec!["mock-shell-0", "mock-shell-1"]
        );
        // …with the alias resolving to the same instance as the canonical id.
        assert_eq!(
            spt_store::shellinfo::resolve_shell_ref(&owlery, "doyle", "Toast").unwrap().0,
            "mock-shell-0"
        );
        // A duplicate alias is a runtime refusal; an invalid one a usage error.
        assert_eq!(spawn("mock-shell", Some("Toast")), 1);
        assert_eq!(spawn("mock-shell", Some("bad:alias")), 2);

        // [unit->REQ-SHELL-1] D3b handshake through the api surface: launch
        // parked a link token (perch offline); `api bind-shell --link` flips
        // it online; a bogus token is refused.
        let shell_perch = perch::resolve_shell_perch_path_in(&owlery, "doyle", "mock-shell-0");
        let token = spt_daemon::shellhost::read_link_token(&shell_perch).expect("token parked");
        let bind = |tok: &str| match parse(&["spt", "api", "--adapter", "mock", "bind-shell", "--link", tok])
            .unwrap()
            .cmd
        {
            Cmd::Api(args) => crate::api::run(args),
            _ => panic!("expected Api"),
        };
        assert_eq!(bind("00000000000000000000000000000000"), 1, "bogus token refused");
        assert_eq!(
            spt_store::shellinfo::read_shell_info(&shell_perch).unwrap().status,
            spt_store::shellinfo::SHELL_STATUS_OFFLINE
        );
        assert_eq!(bind(&token), 0);
        assert_eq!(
            spt_store::shellinfo::read_shell_info(&shell_perch).unwrap().status,
            spt_store::shellinfo::SHELL_STATUS_ONLINE
        );

        // Teardown frees the slot…
        assert_eq!(
            cmd_shell(ShellCmd::Teardown { shell_ref: "Toast".into(), owner: Some("doyle".into()) }),
            0
        );
        assert_eq!(spawn("mock-shell", None), 0);
        assert!(spt_store::shellinfo::resolve_shell_ref(&owlery, "doyle", "mock-shell-0").is_some());

        // …and a deregistered adapter stops minting while existing perches stay.
        spt_runtime::registry::deregister(&adapters, "mock-shell").unwrap();
        assert_eq!(spawn("mock-shell", None), 1);
        assert_eq!(spt_store::shellinfo::list_shells(&owlery, "doyle").len(), 2);

        // A deregistered adapter's instances can't be driven (capabilities
        // resolve from the registered manifest, never the perch) — and the D4
        // surface still refuses.
        assert_eq!(
            cmd_shell(ShellCmd::Cmd {
                shell_ref: "mock-shell-0".into(),
                op: vec!["notify".into()],
                owner: Some("doyle".into()),
            }),
            1
        );
        assert_eq!(
            cmd_shell(ShellCmd::Relink { shell_ref: "mock-shell-0".into(), owner: Some("doyle".into()) }),
            1
        );
    }

    // [unit->REQ-SHELL-2] relink is the manual online switch (M5-D4a):
    // offline-only (an online instance refuses), owner-scoped (a foreign ref
    // resolves nothing), re-launches with a FRESH link token (the old one
    // died with the close), and is not the online flip itself — the perch
    // onlines at the binary's bind, same as spawn.
    #[test]
    fn shell_relink_relaunches_offline_instances_only() {
        let _h = crate::testutil::isolated_home();
        let adapters = spt_store::perch::adapters_dir();
        let owlery = perch::owlery_dir();

        #[cfg(windows)]
        let noop = "cmd /c exit 0";
        #[cfg(unix)]
        let noop = "true";
        let toml_src = format!(
            "[adapter]\nname = \"mock-shell\"\nkind = \"shell\"\nversion = \"1\"\n\
             min_spt_core_version = \"0\"\n\n[shell]\nspawn = \"{noop} --link {{link_token}}\"\n"
        );


242:id = "REQ-MSG-2"
243-title = "spt binary CLI surface: send/ring/ready(+--once)/list/stop/whoami, stable arg shapes + exit codes"
244-required_stages = ["impl", "unit"]  # activated M1 T6-T8: spt binary, clap CLI, all 7 subcommands wired. M7 amendment (plan decision 2, 2026-06-05): `poll` removed — it duplicated `ready` (same run_listen dispatch); `ready --once` absorbs the drain-then-exit semantics. `spt api poll`/`api worker-poll` (machinery namespace) are unaffected. Pre-1.0, zero users, no deprecation shim.
245-[[requirements]]
246-id = "REQ-MSG-3"
247-title = "Ready-agent lifecycle: register perch (info.json + listener + registry address) on ready, drain spooled backlog on startup, clean teardown"
248-required_stages = ["impl", "unit", "int"]  # activated M1 T3 (impl+unit); int added T9: killer-quickstart backlog-drain E2E
249-
250-# ───────────────────────────── Networking / pairing ─────────────────────────
251-[[requirements]]
252-id = "REQ-NODE-IDENTITY"
--
276:id = "REQ-PAIR-3"
277-title = "Fetch current pairing code from any paired node"
278:[Omitted long matching line]
279-[[requirements]]
280:id = "REQ-PAIR-4"
281-title = "Subnet naming on first pairing"
282:required_stages = ["impl", "unit"]  # activated M4-D2f: SubnetStore::create_subnet names the subnet at creation (sole seed-holder, epoch 1; "naming moves to link-start" ADR-0006); the name rides every ceremony (joiner types it, transcript binds it) and reaches the joiner via D2f seed transfer. unit = wire create_new_then_join_learns_named_subnet. CLI naming prompt is later (daemon wiring, D4+) — delivered by M7 REQ-SUBNET-2 (guided `spt subnet join`); int = two-host at D9.
283-# ── Multi-subnet pairing ceremony (ADR-0005 amend, 2026-06-01; activate M4) ──
284-[[requirements]]
285-id = "REQ-PAIR-5"
286-title = "Multi-subnet pairing: subnet-name discovery input, create-new-names-up-front, rendezvous-token hashing"
287-[Omitted long context line]
288-[[requirements]]
289:id = "REQ-PAIR-6"
290-title = "Elevation-gated per-subnet code fetch (UAC/root or elevated agent; else authenticator app)"
291-[Omitted long context line]
292-[[requirements]]
293-id = "REQ-PAIR-7"
294-title = "Subnet icon (inline image metadata, GUI-only consumer)"
295-required_stages = []  # rule 5: GUI milestone (user 2026-06-04) — inline image metadata has no consumer until a GUI exists
296-# ── M7 subnet & quickstart UX (M7-PLAN.md, grilled 2026-06-05; activate at M7 start, rule 5) ──
297-[[requirements]]
298:id = "REQ-SUBNET-1"
299-title = "spt subnet noun namespace: status view (bare + status [NAME] [--nodes]), create (QR/otpauth), show-totp; spt pair deleted"
300:[Omitted long matching line]
301-[[requirements]]
302:id = "REQ-SUBNET-2"
303-title = "Guided join e2e: spt subnet join CLI initiator + always-on daemon pairing responder"
304-[Omitted long context line]
305-[[requirements]]
306:id = "REQ-SUBNET-3"
307-title = "Node labels: hostname-default, gossiped, addressable in @node qualifiers (refuse-on-ambiguity)"
308-required_stages = []  # M7 D2 (plan decision 7). Label defaults to OS hostname, re-checked at daemon startup (hostname change updates it), rides existing registry gossip as an additive serde-default field (old rows parse clean). Pubkey stays the identity; @node qualifiers accept label OR key-prefix; non-unique label resolution refuses-and-qualifies listing candidate key prefixes (CONTEXT.md §node label). Renders `HFENDULEAM (bcead52b…)` in subnet views.
309-[[requirements]]
310:id = "REQ-SUBNET-4"
311-title = "Subnet membership mutations elevation-gated (create = seed reveal; join = trust-boundary enrollment)"
312:required_stages = []  # M7 D3 (plan decision 9; ADR-0005 amendment lands with it). `subnet create` and `subnet join` require OS elevation via the existing REQ-PAIR-6 gate machinery (exit 3 on refusal, no leak/no mutation): an unprivileged process must not mint+leak a subnet secret nor enroll the machine into an attacker's subnet. `subnet status` stays ungated (read-only, no secrets). CONTEXT.md §Pairing & trust updated 2026-06-05.
313-[[requirements]]
314:id = "REQ-DOCS-6"
315-title = "spt how-to <topic>: in-binary task-oriented agent instructions (anti-drift; quickstart prompts point agents at it)"
316-[Omitted long context line]
317-
318-# ── Endpoint access control (ADR-0009, 2026-06-02; activate M4 node-tier) ──
319-[[requirements]]
320-id = "REQ-SEC-1"
321-title = "Per-endpoint access whitelist: origin-node gate, stateful-firewall (reply/outbound exempt), node-now/user-later, outer gate before grants"
322-required_stages = ["impl", "unit"]
323-# ── Subnet notifications (ADR-0007, 2026-06-01; activate M4) ──
324-[[requirements]]


296	# ── M7 subnet & quickstart UX (M7-PLAN.md, grilled 2026-06-05; activate at M7 start, rule 5) ──
297	[[requirements]]
298	id = "REQ-SUBNET-1"
299	title = "spt subnet noun namespace: status view (bare + status [NAME] [--nodes]), create (QR/otpauth), show-totp; spt pair deleted"
300	required_stages = []  # M7 D1/D2 (plan decisions 3, 5, 6, 11). Bare `spt subnet` = flagless status (names, paired-node counts, endpoint counts; never epochs/codes; hint footer; zero-subnet explanatory text). `status [NAME] [--nodes]` adds per-node rows (label, online/offline via gossip-recency + probe-stale hybrid, online/total endpoints). `create <NAME>` mints (sole seed-holder) + prints code/otpauth/terminal-QR + join hint. `show-totp [name]` keeps REQ-PAIR-3/6 semantics. `spt pair` namespace removed outright (no shim).
301	[[requirements]]
302	id = "REQ-SUBNET-2"
303	title = "Guided join e2e: spt subnet join CLI initiator + always-on daemon pairing responder"
304	required_stages = []  # M7 D3 (plan decisions 8, 10) — the join ceremony's PRODUCT surface (the SPAKE2 wire is rig-proven but test-only today). Every member daemon hosts the pre-trust pairing ALPN responder ALWAYS-ON (ADR-0005 one-sided-UX intent; the red-team #11 subnet-global rate limiter + one-ceremony-per-subnet is the standing-listener guard). `spt subnet join [NAME] [--code <CODE>]`: prompts for missing pieces, LAN+relay rendezvous, clean wrong-code re-prompt (bounded, limiter surfaced honestly), actionable no-seed-holder hints, seed transfer + trust pinning + REQ-INST-15 home adoption on join. int = twohost ladder gains a product-surface join rung (CLI initiator vs daemon responder) replacing the test-harness-driven ceremony as pairing evidence.
305	[[requirements]]
306	id = "REQ-SUBNET-3"
307	title = "Node labels: hostname-default, gossiped, addressable in @node qualifiers (refuse-on-ambiguity)"
308	required_stages = []  # M7 D2 (plan decision 7). Label defaults to OS hostname, re-checked at daemon startup (hostname change updates it), rides existing registry gossip as an additive serde-default field (old rows parse clean). Pubkey stays the identity; @node qualifiers accept label OR key-prefix; non-unique label resolution refuses-and-qualifies listing candidate key prefixes (CONTEXT.md §node label). Renders `HFENDULEAM (bcead52b…)` in subnet views.
309	[[requirements]]
310	id = "REQ-SUBNET-4"
311	title = "Subnet membership mutations elevation-gated (create = seed reveal; join = trust-boundary enrollment)"
312	required_stages = []  # M7 D3 (plan decision 9; ADR-0005 amendment lands with it). `subnet create` and `subnet join` require OS elevation via the existing REQ-PAIR-6 gate machinery (exit 3 on refusal, no leak/no mutation): an unprivileged process must not mint+leak a subnet secret nor enroll the machine into an attacker's subnet. `subnet status` stays ungated (read-only, no secrets). CONTEXT.md §Pairing & trust updated 2026-06-05.
313	[[requirements]]
314	id = "REQ-DOCS-6"
315	title = "spt how-to <topic>: in-binary task-oriented agent instructions (anti-drift; quickstart prompts point agents at it)"
316	required_stages = []  # M7 D1/D4 (plan decision 12). Visible command, Agent-commands help group. Topics v1: `ready` (background-task guidance, --once fallback loop, reply mechanics) + `send` (send/ring, reply-to, SENT vs QUEUED); bare `how-to` lists topics. Single source = the binary — docs-site never duplicates the content (the DOCS-STRATEGY anti-drift posture applied to agent guidance); quickstart prompt blocks instruct agents to run it and follow. int = quickstart_e2e asserts the how-to output names the commands the guide relies on.
317

# ── M7 subnet & quickstart UX (M7-PLAN.md, grilled 2026-06-05; activate at M7 start, rule 5) ──
[[requirements]]
id = "REQ-SUBNET-1"
title = "spt subnet noun namespace: status view (bare + status [NAME] [--nodes]), create (QR/otpauth), show-totp; spt pair deleted"
required_stages = []  # M7 D1/D2 (plan decisions 3, 5, 6, 11). Bare `spt subnet` = flagless status (names, paired-node counts, endpoint counts; never epochs/codes; hint footer; zero-subnet explanatory text). `status [NAME] [--nodes]` adds per-node rows (label, online/offline via gossip-recency + probe-stale hybrid, online/total endpoints). `create <NAME>` mints (sole seed-holder) + prints code/otpauth/terminal-QR + join hint. `show-totp [name]` keeps REQ-PAIR-3/6 semantics. `spt pair` namespace removed outright (no shim).
[[requirements]]
id = "REQ-SUBNET-2"
title = "Guided join e2e: spt subnet join CLI initiator + always-on daemon pairing responder"
required_stages = []  # M7 D3 (plan decisions 8, 10) — the join ceremony's PRODUCT surface (the SPAKE2 wire is rig-proven but test-only today). Every member daemon hosts the pre-trust pairing ALPN responder ALWAYS-ON (ADR-0005 one-sided-UX intent; the red-team #11 subnet-global rate limiter + one-ceremony-per-subnet is the standing-listener guard). `spt subnet join [NAME] [--code <CODE>]`: prompts for missing pieces, LAN+relay rendezvous, clean wrong-code re-prompt (bounded, limiter surfaced honestly), actionable no-seed-holder hints, seed transfer + trust pinning + REQ-INST-15 home adoption on join. int = twohost ladder gains a product-surface join rung (CLI initiator vs daemon responder) replacing the test-harness-driven ceremony as pairing evidence.
[[requirements]]
id = "REQ-SUBNET-3"
title = "Node labels: hostname-default, gossiped, addressable in @node qualifiers (refuse-on-ambiguity)"
required_stages = []  # M7 D2 (plan decision 7). Label defaults to OS hostname, re-checked at daemon startup (hostname change updates it), rides existing registry gossip as an additive serde-default field (old rows parse clean). Pubkey stays the identity; @node qualifiers accept label OR key-prefix; non-unique label resolution refuses-and-qualifies listing candidate key prefixes (CONTEXT.md §node label). Renders `HFENDULEAM (bcead52b…)` in subnet views.
[[requirements]]
id = "REQ-SUBNET-4"
title = "Subnet membership mutations elevation-gated (create = seed reveal; join = trust-boundary enrollment)"
required_stages = []  # M7 D3 (plan decision 9; ADR-0005 amendment lands with it). `subnet create` and `subnet join` require OS elevation via the existing REQ-PAIR-6 gate machinery (exit 3 on refusal, no leak/no mutation): an unprivileged process must not mint+leak a subnet secret nor enroll the machine into an attacker's subnet. `subnet status` stays ungated (read-only, no secrets). CONTEXT.md §Pairing & trust updated 2026-06-05.
[[requirements]]
id = "REQ-DOCS-6"
title = "spt how-to <topic>: in-binary task-oriented agent instructions (anti-drift; quickstart prompts point agents at it)"
required_stages = []  # M7 D1/D4 (plan decision 12). Visible command, Agent-commands help group. Topics v1: `ready` (background-task guidance, --once fallback loop, reply mechanics) + `send` (send/ring, reply-to, SENT vs QUEUED); bare `how-to` lists topics. Single source = the binary — docs-site never duplicates the content (the DOCS-STRATEGY anti-drift posture applied to agent guidance); quickstart prompt blocks instruct agents to run it and follow. int = quickstart_e2e asserts the how-to output names the commands the guide relies on.


<tool_use_error>Path does not exist: C:\Users\decid\Documents\projects\spt-core\xtask\src. Note: your current working directory is C:\Users\decid\Documents\projects\spt-core.</tool_use_error>

Error: Path does not exist: C:\Users\decid\Documents\projects\spt-core\xtask\src. Note: your current working directory is C:\Users\decid\Documents\projects\spt-core.


Bash

List xtask and spt sources


Error: Exit code 2
ls: cannot access 'C:/Users/decid/Documents/projects/spt-core/xtask/src': No such file or directory
C:/Users/decid/Documents/projects/spt-core/crates/spt/src:
api
cli.rs
elevation.rs
main.rs
roster.rs
testutil.rs
wansend.rs


Found 11 files
M7-PLAN.md
crates\xtask\src\main.rs
docs-site\llms.txt
docs-site\src\quickstart\messaging.md
docs-site\src\index.md
docs-site\src\self-update\overview.md
docs-site\src\instances\overview.md
docs-site\src\networking\overview.md
docs-site\src\messaging\overview.md
docs-site\src\concepts\overview.md
docs-site\src\SUMMARY.md


1-//! spt-core docs/site generation (M6-D5) — the anti-drift plumbing behind
2-\! DOCS-STRATEGY's "generation is part of the build" commitment (REQ-DOCS-5):
3-//!
4://! - `gen` — regenerate the checked-in generated docs (CLI reference).
5://! - `check` — drift gate: regenerate + diff the CLI reference, and
6-//!   validate every page link in the curated llms.txt.
7-//! - `site <outdir>` — assemble the full publish tree for the spt-releases
8-\!   Pages branch: mdBook HTML + raw `.md` alongside it + llms.txt /
9-//!   llms-full.txt / manifest.schema.json / install scripts at site root +
10-//!   rustdoc for the SDK crates.
--
55-
56-// ---------------------------------------------------------------- CLI ref
57-
58-/// Run the workspace `spt` binary with `args` + `--help`, normalized to the
59-/// `spt` name (Windows prints `spt.exe`, which would make the generated
60:/// reference platform-dependent).
61-fn help_for(bin: &Path, args: &[&str]) -> String {
62-    let mut cmd = Command::new(bin);
63-    cmd.args(args).arg("--help");
64-    let out = cmd.output().expect("run spt --help");
65-    let text = String::from_utf8_lossy(&out.stdout);
--
101-        }
102-    }
103-    cmds
104-}
105-
106:/// The generated CLI reference: every command + nested subcommand's help,
107-/// verbatim. Help text is a first-class doc surface (DOCS-STRATEGY), so the
108:/// reference IS the binary's own words — it cannot drift (REQ-DOCS-5).
109-// [impl->REQ-DOCS-5]
110:fn cli_reference(bin: &Path) -> String {
111-    let mut out = String::from(
112:        "# CLI reference\n\n\
113-         > **Generated** from the `spt` binary's own `--help` output \
114-         (`cargo run -p xtask -- gen`) and drift-gated in CI — this page \
115-         cannot disagree with the binary. Do not edit by hand.\n\n",
116-    );
117-    let root_help = help_for(bin, &[]);
--
154-}
155-
156-fn gen(check: bool) {
157-    let root = repo_root();
158-    let bin = spt_bin(&root);
159:    let generated = cli_reference(&bin);
160:    let path = root.join("docs-site/src/cli/reference.md");
161-    if check {
162-        let on_disk = std::fs::read_to_string(&path)
163:            .expect("checked-in cli/reference.md (run `cargo run -p xtask -- gen`)")
164-            .replace("\r\n", "\n");
165-        if on_disk != generated {
166-            eprintln!(
167:                "xtask check: docs-site/src/cli/reference.md drifted from the \
168-                 binary's --help — run `cargo run -p xtask -- gen` and commit"
169-            );
170-            std::process::exit(1);
171-        }
172-    } else {
173:        std::fs::write(&path, &generated).expect("write cli reference");
174-        eprintln!("xtask gen: wrote {}", path.display());
175-    }
176-}
177-
178-// ---------------------------------------------------------------- llms
--
360-    let identity = signing_identity();
361-    println!("signing as: {} (key_id {key_id})", identity.public_key().to_hex());
362-    sign_artifacts(&identity, &key_id, version, expires_days, &artifacts);
363-}
364-
365:/// Resolve the signing identity. Preference order:
366-/// 1. `SPT_RELEASE_SEED_CMD` — a command that prints the seed hex (the
367-///    password-manager CLI integration, e.g.
368-\    `op read "op://Private/spt release primary/seed"` or
369-\    `bw get notes spt-release-primary`): the seed never sits in shell
370-///    history or a long-lived env var.