Skip to main content

omni_cli/
lib.rs

1//! omni — shared command logic for the CLI and the MCP server.
2//!
3//! The same typed command contracts back both the clap CLI surface and the
4//! `mcp-cli` `ToolRouter`, so `omni mcp stdio` is a valid MCP server that
5//! exposes exactly the operations the CLI performs. This mirrors the pattern
6//! every harryaskham ecosystem project follows (see cacophony templates/).
7//!
8//! The `omni` binary is built on the mcp-cli / updatable-cli / feedback-cli
9//! stack (the `status` / `update` / `mcp` surfaces) plus these modules, all
10//! landed:
11//!
12//! - [`wire`] — canonical NDJSON InjectionCommand schema (bd-44dac8).
13//! - [`transport`] — Android adb am-broadcast (bd-17026f), direct
14//!   NDJSON-over-TCP, and the iOS/watch relay (bd-a5452f).
15//! - [`config`] — declarative connection targets (bd-2da1d9); [`dispatch`]
16//!   fans a cast out to a target's endpoints (`--target`, bd-bfb732).
17//! - [`cast`] — stdin streaming (bd-5cd61e); [`receive`] — relay casts as
18//!   flushed NDJSON (`omni listen|get`); [`daemon`] / [`service`] — desktop
19//!   replay daemon + service units (bd-1eefe5).
20
21use schemars::JsonSchema;
22use serde::{Deserialize, Serialize};
23
24use feedback_cli::{FeedbackConfig, ReportStrategy, Reporter, WebhookConfig};
25use mcp_cli::ToolRouter;
26
27pub mod cast;
28pub mod config;
29pub mod daemon;
30pub mod dispatch;
31mod event_bus;
32pub mod events;
33pub mod macros;
34pub mod receive;
35pub mod service;
36pub mod transport;
37pub mod wire;
38
39/// GitHub `owner/repo` the self-updater pulls release assets from.
40pub const UPDATE_REPO_SLUG: &str = "harryaskham/omni-cli";
41
42/// Tool name on disk, used by the self-updater for `<tool>` / `<tool>_next`.
43/// This is the binary name (`omni`).
44pub const TOOL_NAME: &str = "omni";
45
46/// Same public error contract; the SDK provides the optional MCP trait adapter.
47pub use omni_sdk::AppError;
48
49// ---------------------------------------------------------------------------
50// status
51// ---------------------------------------------------------------------------
52
53/// Input for the `status` command / `status` MCP tool.
54#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
55pub struct StatusInput {}
56
57/// Output for the `status` command / `status` MCP tool.
58#[derive(Debug, Clone, Serialize, JsonSchema)]
59pub struct StatusOutput {
60    pub name: String,
61    pub version: String,
62    pub update_repo: String,
63    pub feedback: bool,
64    pub feedback_destination: String,
65}
66
67/// `status` command implementation shared by the CLI and the MCP tool.
68///
69/// Resolves feedback configuration from the environment; see [`status_with`]
70/// for the pure core that takes an explicit [`FeedbackConfig`].
71#[must_use]
72pub fn status(input: &StatusInput) -> StatusOutput {
73    status_with(input, &feedback_config())
74}
75
76/// Pure core of [`status`], testable without depending on the ambient process
77/// environment. `status` resolves feedback config via `feedback_config()`
78/// (which consults `FEEDBACK_WEBHOOK_*`); this variant takes the resolved
79/// [`FeedbackConfig`] explicitly so callers and tests are deterministic.
80/// Mirrors the `updater_config` / `updater_config_with` split, which exists for
81/// the same reason (env mutation is `unsafe` under this crate's
82/// `unsafe_code = forbid`).
83#[must_use]
84pub fn status_with(_input: &StatusInput, feedback_config: &FeedbackConfig) -> StatusOutput {
85    let feedback = Reporter::from_config(feedback_config);
86    StatusOutput {
87        name: TOOL_NAME.to_owned(),
88        version: env!("CARGO_PKG_VERSION").to_owned(),
89        update_repo: UPDATE_REPO_SLUG.to_owned(),
90        feedback: feedback.is_enabled(),
91        feedback_destination: feedback.destination(),
92    }
93}
94
95// ---------------------------------------------------------------------------
96// doctor
97// ---------------------------------------------------------------------------
98
99/// Severity of a single [`DoctorCheck`].
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)]
101#[serde(rename_all = "lowercase")]
102pub enum CheckStatus {
103    Ok,
104    Warn,
105    Fail,
106}
107
108/// One environment/health probe result.
109#[derive(Debug, Clone, Serialize, JsonSchema)]
110pub struct DoctorCheck {
111    pub name: String,
112    pub status: CheckStatus,
113    pub detail: String,
114}
115
116impl DoctorCheck {
117    fn ok(name: &str, detail: impl Into<String>) -> Self {
118        Self {
119            name: name.to_owned(),
120            status: CheckStatus::Ok,
121            detail: detail.into(),
122        }
123    }
124    fn warn(name: &str, detail: impl Into<String>) -> Self {
125        Self {
126            name: name.to_owned(),
127            status: CheckStatus::Warn,
128            detail: detail.into(),
129        }
130    }
131    fn fail(name: &str, detail: impl Into<String>) -> Self {
132        Self {
133            name: name.to_owned(),
134            status: CheckStatus::Fail,
135            detail: detail.into(),
136        }
137    }
138}
139
140/// Input for the `doctor` command / `doctor` MCP tool.
141#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
142pub struct DoctorInput {
143    /// Run bounded runtime reachability probes (adb-attached devices, and a TCP
144    /// connect to each configured tcp/relay target) in addition to the static
145    /// checks. Off by default to keep `omni doctor` fast (bd-3c9554).
146    #[serde(default)]
147    pub probe: bool,
148}
149
150/// Aggregated runtime health report for `omni doctor`.
151#[derive(Debug, Clone, Serialize, JsonSchema)]
152pub struct DoctorReport {
153    pub name: String,
154    pub version: String,
155    pub checks: Vec<DoctorCheck>,
156    pub warnings: usize,
157    pub failures: usize,
158    /// True when no check failed (warnings are non-fatal).
159    pub healthy: bool,
160}
161
162/// Locate an executable by name on `PATH` (best-effort, no execution).
163fn find_on_path(bin: &str) -> Option<std::path::PathBuf> {
164    let path = std::env::var_os("PATH")?;
165    std::env::split_paths(&path)
166        .map(|dir| dir.join(bin))
167        .find(|candidate| candidate.is_file())
168}
169
170/// Bounded TCP-reachability connect timeout for `omni doctor --probe`.
171const PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(500);
172
173/// Strip an optional `tcp://`/`tcps://` scheme from a relay url -> bare
174/// host:port (mirrors dispatch::relay_addr).
175fn strip_relay_scheme(url: &str) -> &str {
176    url.strip_prefix("tcp://")
177        .or_else(|| url.strip_prefix("tcps://"))
178        .unwrap_or(url)
179}
180
181/// Bounded TCP reachability probe: resolve `addr` and try to connect within
182/// `timeout`. `Ok(())` reachable; `Err(message)` otherwise. Never blocks beyond
183/// the timeout per resolved socket address.
184fn probe_tcp(addr: &str, timeout: std::time::Duration) -> Result<(), String> {
185    use std::net::ToSocketAddrs;
186    let sockaddrs: Vec<_> = addr
187        .to_socket_addrs()
188        .map_err(|e| format!("unresolvable: {e}"))?
189        .collect();
190    if sockaddrs.is_empty() {
191        return Err("no addresses resolved".to_owned());
192    }
193    let mut last = "connect failed".to_owned();
194    for sa in sockaddrs {
195        match std::net::TcpStream::connect_timeout(&sa, timeout) {
196            Ok(_) => return Ok(()),
197            Err(e) => last = e.to_string(),
198        }
199    }
200    Err(last)
201}
202
203/// Run a command to completion bounded by `timeout`. On timeout returns `Err`
204/// without blocking the caller (the worker thread is detached and reaps the
205/// child when it eventually exits), so a wedged `adb` cannot hang `doctor`.
206fn run_bounded_output(
207    mut cmd: std::process::Command,
208    timeout: std::time::Duration,
209) -> Result<std::process::Output, String> {
210    let (tx, rx) = std::sync::mpsc::channel();
211    std::thread::spawn(move || {
212        let _ = tx.send(cmd.output());
213    });
214    match rx.recv_timeout(timeout) {
215        Ok(Ok(output)) => Ok(output),
216        Ok(Err(e)) => Err(e.to_string()),
217        Err(_) => Err(format!("timed out after {}ms", timeout.as_millis())),
218    }
219}
220
221/// Serials of `adb devices` entries in the `device` state (bd-3c9554 probe).
222fn adb_attached_devices() -> Result<Vec<String>, String> {
223    let mut cmd = std::process::Command::new("adb");
224    cmd.arg("devices");
225    let out = run_bounded_output(cmd, std::time::Duration::from_millis(2500))?;
226    if !out.status.success() {
227        return Err(format!(
228            "adb devices exited {}: {}",
229            out.status,
230            String::from_utf8_lossy(&out.stderr).trim()
231        ));
232    }
233    let text = String::from_utf8_lossy(&out.stdout);
234    Ok(text
235        .lines()
236        .skip(1) // header: "List of devices attached"
237        .filter_map(|line| {
238            let mut it = line.split_whitespace();
239            let serial = it.next()?;
240            if it.next() == Some("device") {
241                Some(serial.to_owned())
242            } else {
243                None
244            }
245        })
246        .collect())
247}
248
249/// `doctor` implementation shared by the CLI and the MCP tool.
250///
251/// Read-only RUNTIME health check: validates the resolved connection config,
252/// the optional `adb` dependency for the `--adb` Android transport, the
253/// feedback routing, and the self-update repo. This is distinct from
254/// `scripts/doctor.sh` (`nix run .#doctor`), which checks repo conventions for
255/// project maintainers rather than a user's runtime environment.
256#[must_use]
257pub fn doctor(input: &DoctorInput) -> DoctorReport {
258    doctor_with(None, input.probe)
259}
260
261/// `doctor` honoring an explicit config path (CLI `--config`) and an optional
262/// `--probe` for bounded runtime reachability checks (bd-3c9554).
263#[must_use]
264pub fn doctor_with(config_path: Option<&std::path::Path>, probe: bool) -> DoctorReport {
265    let mut checks = Vec::new();
266
267    checks.push(DoctorCheck::ok(
268        "binary",
269        format!("{TOOL_NAME} v{}", env!("CARGO_PKG_VERSION")),
270    ));
271
272    // Config: resolves ~/.config/omni/config.yaml (or --config). Retained so the
273    // optional reachability probes below can read the configured targets.
274    let loaded_config = match config::load(config_path) {
275        Ok(loaded) => {
276            let report = config::ConfigReport::from_loaded(&loaded);
277            if report.exists {
278                checks.push(DoctorCheck::ok(
279                    "config",
280                    format!("{} target(s) at {}", report.targets.len(), report.path),
281                ));
282            } else {
283                checks.push(DoctorCheck::warn(
284                    "config",
285                    format!("no config at {} (using empty defaults)", report.path),
286                ));
287            }
288            Some(loaded)
289        }
290        Err(error) => {
291            checks.push(DoctorCheck::fail("config", error.to_string()));
292            None
293        }
294    };
295
296    // adb: required for `omni send --adb` (Android am-broadcast transport).
297    let adb_present = find_on_path("adb");
298    match &adb_present {
299        Some(p) => checks.push(DoctorCheck::ok("adb", format!("found at {}", p.display()))),
300        None => checks.push(DoctorCheck::warn(
301            "adb",
302            "not on PATH — `omni send --adb` needs the Android platform-tools `adb`",
303        )),
304    }
305
306    // feedback: where command failures / perf events / feedback are routed.
307    let feedback = Reporter::from_config(&feedback_config());
308    if feedback.is_enabled() {
309        checks.push(DoctorCheck::ok(
310            "feedback",
311            format!("routing to {}", feedback.destination()),
312        ));
313    } else {
314        checks.push(DoctorCheck::ok(
315            "feedback",
316            "stderr (set FEEDBACK_WEBHOOK_BASE_URL or FEEDBACK_WEBHOOK_URL to route to omni-cli beads)",
317        ));
318    }
319
320    // self-update repo.
321    checks.push(DoctorCheck::ok(
322        "update",
323        format!("self-update from {UPDATE_REPO_SLUG}"),
324    ));
325
326    // Optional bounded runtime reachability probes (bd-3c9554). Off by default so
327    // `omni doctor` stays fast; opt in with `--probe`. All probes are bounded
328    // and non-fatal (warn, never hang).
329    if probe {
330        if adb_present.is_some() {
331            match adb_attached_devices() {
332                Ok(devices) if devices.is_empty() => checks.push(DoctorCheck::warn(
333                    "adb-devices",
334                    "no Android devices in `device` state (adb devices)",
335                )),
336                Ok(devices) => checks.push(DoctorCheck::ok(
337                    "adb-devices",
338                    format!("{} device(s): {}", devices.len(), devices.join(", ")),
339                )),
340                Err(e) => checks.push(DoctorCheck::warn("adb-devices", e)),
341            }
342        }
343
344        // Configured single-endpoint tcp/relay targets: bounded TCP connect.
345        // Group targets are skipped (members are probed as their own named
346        // targets); adb/termux are not TCP-reachable so are skipped.
347        if let Some(loaded) = &loaded_config {
348            for (name, target) in &loaded.config.targets {
349                let addr = match target.transport.as_ref() {
350                    Some(config::Transport::Tcp { host, port }) => Some(format!("{host}:{port}")),
351                    Some(config::Transport::Relay { url, .. }) => {
352                        Some(strip_relay_scheme(url).to_owned())
353                    }
354                    _ => None,
355                };
356                if let Some(addr) = addr {
357                    match probe_tcp(&addr, PROBE_TIMEOUT) {
358                        Ok(()) => checks.push(DoctorCheck::ok(
359                            &format!("target:{name}"),
360                            format!("{addr} reachable"),
361                        )),
362                        Err(e) => checks.push(DoctorCheck::warn(
363                            &format!("target:{name}"),
364                            format!("{addr} unreachable: {e}"),
365                        )),
366                    }
367                }
368            }
369        }
370    }
371
372    let failures = checks
373        .iter()
374        .filter(|c| c.status == CheckStatus::Fail)
375        .count();
376    let warnings = checks
377        .iter()
378        .filter(|c| c.status == CheckStatus::Warn)
379        .count();
380    DoctorReport {
381        name: TOOL_NAME.to_owned(),
382        version: env!("CARGO_PKG_VERSION").to_owned(),
383        checks,
384        warnings,
385        failures,
386        healthy: failures == 0,
387    }
388}
389
390// ---------------------------------------------------------------------------
391// MCP router
392// ---------------------------------------------------------------------------
393
394/// Context shared by every MCP tool. Kept minimal in the scaffold.
395#[derive(Debug, Default, Clone)]
396pub struct AppContext;
397
398/// Build the [`ToolRouter`] exposing the CLI's command surface as MCP tools,
399/// including the `self_update_*` tools contributed by `updatable-cli` and the
400/// Input for the `send` MCP tool: a single keystroke command, optionally cast
401/// through a relay. Set exactly one of `text`/`key`/`combo`/`scroll`.
402#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
403pub struct SendInput {
404    /// A literal string to type (`.text`).
405    #[serde(default)]
406    pub text: Option<String>,
407    /// A semantic key name, e.g. "enter", "tab", "esc" (`.key`).
408    #[serde(default)]
409    pub key: Option<String>,
410    /// An omni combo string, e.g. "ctrl,a,g" (`.combo`).
411    #[serde(default)]
412    pub combo: Option<String>,
413    /// Scroll lines; positive = down, negative = up (`.scroll`).
414    #[serde(default)]
415    pub scroll: Option<i64>,
416    /// Relay hub to cast through (host:port). If unset, only the NDJSON wire
417    /// line is returned (no transport is performed).
418    #[serde(default)]
419    pub relay: Option<String>,
420    /// Target device id for the relay path (default "all").
421    #[serde(default)]
422    pub device: Option<String>,
423    /// Shared relay token (else the OMNI_RELAY_TOKEN env var). Used only when
424    /// `relay` is set.
425    #[serde(default)]
426    pub token: Option<String>,
427}
428
429/// Output for the `send` MCP tool.
430#[derive(Debug, Clone, Serialize, JsonSchema)]
431pub struct SendOutput {
432    /// The canonical NDJSON wire line (v=1 InjectionCommand) for the command.
433    pub wire: String,
434    /// Whether the command was cast through a relay (vs wire-line only).
435    pub cast: bool,
436    /// When cast: whether the relay acknowledged delivery (bd-0b4e45).
437    #[serde(skip_serializing_if = "Option::is_none")]
438    pub acked: Option<bool>,
439    /// When cast: how many connected devices received it (0 = the target
440    /// device id is not connected to the relay).
441    #[serde(skip_serializing_if = "Option::is_none")]
442    pub delivered: Option<usize>,
443}
444
445/// Build the [`wire::InjectionCommand`] for a [`SendInput`], rejecting inputs
446/// that do not set exactly one of text/key/combo/scroll.
447fn build_injection(input: &SendInput) -> Result<wire::InjectionCommand, AppError> {
448    let set = usize::from(input.text.is_some())
449        + usize::from(input.key.is_some())
450        + usize::from(input.combo.is_some())
451        + usize::from(input.scroll.is_some());
452    if set != 1 {
453        return Err(AppError::validation(
454            "send_kind",
455            "set exactly one of text, key, combo, scroll".to_owned(),
456        ));
457    }
458    Ok(if let Some(t) = &input.text {
459        wire::InjectionCommand::text(t)
460    } else if let Some(k) = &input.key {
461        wire::InjectionCommand::key(k)
462    } else if let Some(c) = &input.combo {
463        wire::InjectionCommand::combo(c)
464    } else {
465        wire::InjectionCommand::scroll(input.scroll.unwrap_or(0))
466    })
467}
468
469/// `send` MCP tool: cast a keystroke command, mirroring `omni send`. With
470/// `relay`, casts through the hub and reports delivery; otherwise returns the
471/// wire line so an agent can preview/generate the command.
472///
473/// # Errors
474/// [`AppError::validation`] on a malformed command or missing token;
475/// [`AppError::internal`] on a relay I/O failure.
476pub fn send_tool(input: &SendInput) -> Result<SendOutput, AppError> {
477    let cmd = build_injection(input)?;
478    let wire = cmd.json_line()?;
479    if let Some(addr) = &input.relay {
480        let token = transport::relay::resolve_token(input.token.as_deref())?;
481        let device = input
482            .device
483            .as_deref()
484            .unwrap_or(transport::relay::DEVICE_ALL);
485        let delivery = transport::relay::cast_via_relay(addr, &token, device, &[cmd])?;
486        Ok(SendOutput {
487            wire,
488            cast: true,
489            acked: Some(delivery.acked),
490            delivered: Some(delivery.delivered),
491        })
492    } else {
493        Ok(SendOutput {
494            wire,
495            cast: false,
496            acked: None,
497            delivered: None,
498        })
499    }
500}
501
502/// Input for the `devices` MCP tool: which devices are connected to a relay.
503#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
504pub struct DevicesInput {
505    /// Relay hub to query (host:port).
506    pub relay: String,
507    /// Shared relay token (else the OMNI_RELAY_TOKEN env var).
508    #[serde(default)]
509    pub token: Option<String>,
510}
511
512/// Output for the `devices` MCP tool.
513#[derive(Debug, Clone, Serialize, JsonSchema)]
514pub struct DevicesOutput {
515    /// Device ids currently registered to the relay (sorted).
516    pub devices: Vec<String>,
517}
518
519/// `devices` MCP tool: list the device ids connected to a relay hub (mirrors
520/// `omni devices --relay`, bd-bc8b5c).
521///
522/// # Errors
523/// [`AppError::validation`] on a missing token; [`AppError::internal`] on a
524/// relay I/O failure.
525pub fn devices_tool(input: &DevicesInput) -> Result<DevicesOutput, AppError> {
526    let token = transport::relay::resolve_token(input.token.as_deref())?;
527    let devices = transport::relay::list_relay_devices(&input.relay, &token)?;
528    Ok(DevicesOutput { devices })
529}
530
531/// `feedback_*` tools contributed by `feedback-cli`.
532#[must_use]
533pub fn build_router() -> ToolRouter<AppContext> {
534    let mut router = ToolRouter::new();
535
536    router.add_typed_tool(
537        "status",
538        "Report this CLI's name, version, update repo, and capabilities.",
539        |_ctx: &AppContext, input: StatusInput| Ok::<_, AppError>(status(&input)),
540    );
541
542    // Config surface (bd-2da1d9): report the resolved connection targets from
543    // ~/.config/omni/config.yaml so the MCP surface mirrors `omni config`.
544    router.add_typed_tool(
545        "config",
546        "Show the resolved omni connection config (targets + transports) from ~/.config/omni/config.yaml.",
547        |_ctx: &AppContext, _input: config::ConfigInput| config::report(),
548    );
549
550    // Doctor surface (bd-d3b877): read-only runtime health checks so the MCP
551    // surface mirrors `omni doctor`.
552    router.add_typed_tool(
553        "doctor",
554        "Run read-only runtime health checks (config, adb, feedback, self-update) for omni.",
555        |_ctx: &AppContext, input: DoctorInput| Ok::<_, AppError>(doctor(&input)),
556    );
557
558    // Keystroke-casting surface: expose `send` and `devices` over MCP so an
559    // agent can actually cast keystrokes and see connected devices, not just
560    // query status/config/doctor.
561    router.add_typed_tool(
562        "send",
563        "Cast a keystroke command (text/key/combo/scroll) to the omni keyboard. With `relay` set, casts through the relay hub and reports delivery (delivered=0 means the target device is not connected); without `relay`, returns the canonical NDJSON wire line.",
564        |_ctx: &AppContext, input: SendInput| send_tool(&input),
565    );
566    router.add_typed_tool(
567        "devices",
568        "List the device ids currently connected to a relay hub (host:port via `relay`).",
569        |_ctx: &AppContext, input: DevicesInput| devices_tool(&input),
570    );
571
572    // updatable-cli contributes self_update_status / self_update_check /
573    // self_update_run, so `omni update` works end-to-end (bd-7b8ba4).
574    updatable_cli::register_update_tool(&mut router, |_ctx: &AppContext| updater_config());
575
576    // feedback-cli contributes feedback_report / feedback_status, so command
577    // failures, perf observations, or operator feedback can be routed back to a
578    // project webhook (-> omni-cli beads, bd-afbb18), caco logging, or stderr.
579    feedback_cli::register_feedback_tools(&mut router, |_ctx: &AppContext| feedback_config());
580
581    router
582}
583
584/// The self-updater configuration (bd-7b8ba4 builds on this).
585///
586/// omni-cli is a PRIVATE repo, so the GitHub releases API + asset downloads need
587/// auth — an unauthenticated `omni update` 404s on `releases/latest`. Source a
588/// bearer token from `GITHUB_TOKEN` if set, otherwise fall back to the local
589/// `gh` CLI (`gh auth token`). Harmless for a public repo (the header is just
590/// ignored), so this is correct either way.
591#[must_use]
592pub fn updater_config() -> updatable_cli::UpdaterConfig {
593    updater_config_with(std::env::var("GITHUB_TOKEN").ok())
594}
595
596/// Pure core of [`updater_config`], testable without env mutation (which is
597/// `unsafe` under this crate's `unsafe_code = "forbid"`). Always enables the
598/// `gh auth token` fallback for the private release repo, and uses an explicit
599/// non-empty token when provided.
600#[must_use]
601pub fn updater_config_with(github_token: Option<String>) -> updatable_cli::UpdaterConfig {
602    let cfg =
603        updatable_cli::UpdaterConfig::new(TOOL_NAME, env!("CARGO_PKG_VERSION"), UPDATE_REPO_SLUG)
604            .with_gh_token_fallback(true);
605    match github_token {
606        Some(token) if !token.trim().is_empty() => cfg.with_github_token(token),
607        _ => cfg,
608    }
609}
610
611/// Feedback reporting configuration. Loaded from the environment convention:
612///
613/// - `FEEDBACK_WEBHOOK_URL` selects webhook reporting with an explicit full URL,
614/// - `FEEDBACK_WEBHOOK_BASE_URL` (Harry's canonical global-hook namespace) routes
615///   to `<base>/<project>` — e.g. `.../hooks/global/omni-cli` — so omni feedback
616///   lands on its OWN hook sub-path, not a shared one (bd-bc5976). An explicit
617///   `FEEDBACK_WEBHOOK_URL` wins over the base URL.
618/// - `FEEDBACK_WEBHOOK_TOKEN_ENV` points at the bearer-token env var,
619/// - `FEEDBACK_COMPONENT` / `FEEDBACK_PROJECT` override the defaults,
620/// - unset means JSON lines to stderr (safe local default).
621#[must_use]
622pub fn feedback_config() -> FeedbackConfig {
623    let mut config = FeedbackConfig::from_env();
624    config.component.get_or_insert_with(|| TOOL_NAME.to_owned());
625    config.project.get_or_insert_with(|| {
626        std::env::var("CACOPHONY_PROJECT").unwrap_or_else(|_| "omni-cli".to_owned())
627    });
628    // bd-bc5976: feedback-cli's from_env only understands a full
629    // FEEDBACK_WEBHOOK_URL. Under Harry's canonical global-hook design the shell
630    // exports only FEEDBACK_WEBHOOK_BASE_URL + one shared token, and each project
631    // POSTs to <base>/<project>. So when no explicit URL was set but a base URL
632    // is present, target this project's own sub-path — keeping omni-cli/tendril/
633    // ... feedback on separate hooks instead of spamming one shared board.
634    if matches!(config.strategy, ReportStrategy::Stderr) {
635        if let Ok(base) = std::env::var("FEEDBACK_WEBHOOK_BASE_URL") {
636            let base = base.trim();
637            if !base.is_empty() {
638                let sub_path = config.project.as_deref().unwrap_or("omni-cli").to_owned();
639                config.strategy = ReportStrategy::Webhook(WebhookConfig {
640                    url: feedback_webhook_url(base, &sub_path),
641                    token_env: std::env::var("FEEDBACK_WEBHOOK_TOKEN_ENV").ok(),
642                    ..WebhookConfig::default()
643                });
644            }
645        }
646    }
647    config
648}
649
650/// Join a feedback webhook base URL and a project sub-path into the full
651/// endpoint, tolerating a trailing `/` on the base and a leading `/` on the
652/// sub-path (bd-bc5976).
653#[must_use]
654fn feedback_webhook_url(base: &str, sub_path: &str) -> String {
655    format!(
656        "{}/{}",
657        base.trim_end_matches('/'),
658        sub_path.trim_start_matches('/')
659    )
660}
661
662#[cfg(test)]
663mod tests {
664    use super::*;
665
666    #[test]
667    fn status_reports_tool_metadata() {
668        // Use an explicit default FeedbackConfig so the test is hermetic and does
669        // not read the ambient process env. `status()` resolves feedback via
670        // FEEDBACK_WEBHOOK_* env, which inside an agent session points at a
671        // wired-but-tokenless omni-feedback webhook and fail-closes to
672        // "disabled"; the pure `status_with` core keeps this assertion
673        // deterministic regardless of ambient env (bd-fc37a8).
674        let s = status_with(&StatusInput::default(), &FeedbackConfig::default());
675        assert_eq!(s.name, TOOL_NAME);
676        assert_eq!(s.update_repo, UPDATE_REPO_SLUG);
677        assert!(s.feedback);
678        assert_eq!(s.feedback_destination, "stderr");
679    }
680
681    #[test]
682    fn updater_config_enables_auth_for_private_repo() {
683        // No explicit token: the gh-auth-token fallback is enabled so a private
684        // repo's releases/latest is reachable; no inline token.
685        let c = updater_config_with(None);
686        assert!(
687            c.gh_token_fallback,
688            "gh auth token fallback must be enabled"
689        );
690        assert_eq!(c.github_token, None);
691        // An explicit non-empty token is used (and the fallback stays on).
692        let c = updater_config_with(Some("ghp_example".to_string()));
693        assert_eq!(c.github_token.as_deref(), Some("ghp_example"));
694        assert!(c.gh_token_fallback);
695        // A blank/whitespace token is ignored (falls back to gh).
696        let c = updater_config_with(Some("   ".to_string()));
697        assert_eq!(c.github_token, None);
698        assert!(c.gh_token_fallback);
699    }
700
701    #[test]
702    fn doctor_reports_core_runtime_checks() {
703        let r = doctor(&DoctorInput::default());
704        assert_eq!(r.name, TOOL_NAME);
705        assert_eq!(r.version, env!("CARGO_PKG_VERSION"));
706        let names: Vec<&str> = r.checks.iter().map(|c| c.name.as_str()).collect();
707        for expected in ["binary", "config", "adb", "feedback", "update"] {
708            assert!(names.contains(&expected), "missing doctor check {expected}");
709        }
710        // healthy is the derived no-failures invariant; warnings stay non-fatal.
711        assert_eq!(r.healthy, r.failures == 0);
712        assert_eq!(
713            r.failures,
714            r.checks
715                .iter()
716                .filter(|c| c.status == CheckStatus::Fail)
717                .count()
718        );
719    }
720
721    #[test]
722    fn strip_relay_scheme_drops_known_schemes() {
723        assert_eq!(strip_relay_scheme("tcp://relay:9000"), "relay:9000");
724        assert_eq!(strip_relay_scheme("tcps://relay:9000"), "relay:9000");
725        assert_eq!(strip_relay_scheme("relay:9000"), "relay:9000");
726    }
727
728    #[test]
729    fn probe_tcp_reaches_a_live_listener_and_fails_on_closed_port() {
730        use std::net::TcpListener;
731        use std::time::Duration;
732        // Live listener -> reachable.
733        let listener = TcpListener::bind("127.0.0.1:0").expect("bind ephemeral port");
734        let addr = listener.local_addr().unwrap().to_string();
735        assert!(
736            probe_tcp(&addr, Duration::from_millis(500)).is_ok(),
737            "live listener should be reachable"
738        );
739        // Drop the listener -> the port is closed -> connect should fail fast.
740        drop(listener);
741        let err = probe_tcp(&addr, Duration::from_millis(500));
742        assert!(err.is_err(), "closed port should be unreachable: {err:?}");
743    }
744
745    #[test]
746    fn probe_tcp_rejects_unresolvable_addr() {
747        use std::time::Duration;
748        // Missing port -> not a socket addr.
749        assert!(probe_tcp("not-a-host-without-port", Duration::from_millis(200)).is_err());
750    }
751
752    #[test]
753    fn router_exposes_cli_and_stack_tools() {
754        let names: Vec<String> = build_router()
755            .tool_metadata()
756            .into_iter()
757            .map(|m| m.name)
758            .collect();
759        for expected in [
760            "status",
761            "config",
762            "doctor",
763            "self_update_status",
764            "self_update_check",
765            "self_update_run",
766            "feedback_report",
767            "feedback_status",
768            "send",
769            "devices",
770        ] {
771            assert!(
772                names.iter().any(|n| n == expected),
773                "missing tool {expected}"
774            );
775        }
776    }
777
778    #[test]
779    fn send_tool_returns_wire_line_without_relay() {
780        let out = send_tool(&SendInput {
781            text: Some("hi".into()),
782            ..Default::default()
783        })
784        .unwrap();
785        assert!(!out.cast);
786        assert!(out.acked.is_none() && out.delivered.is_none());
787        assert_eq!(out.wire, r#"{"v":1,"type":"text","text":"hi"}"#);
788
789        let out = send_tool(&SendInput {
790            key: Some("enter".into()),
791            ..Default::default()
792        })
793        .unwrap();
794        assert_eq!(out.wire, r#"{"v":1,"type":"key","key":"enter"}"#);
795
796        let out = send_tool(&SendInput {
797            scroll: Some(3),
798            ..Default::default()
799        })
800        .unwrap();
801        assert_eq!(out.wire, r#"{"v":1,"type":"scroll","amount":3}"#);
802    }
803
804    #[test]
805    fn send_tool_rejects_wrong_arity() {
806        assert_eq!(
807            send_tool(&SendInput::default()).unwrap_err().code(),
808            "send_kind"
809        );
810        assert_eq!(
811            send_tool(&SendInput {
812                text: Some("a".into()),
813                key: Some("enter".into()),
814                ..Default::default()
815            })
816            .unwrap_err()
817            .code(),
818            "send_kind"
819        );
820    }
821
822    #[test]
823    fn send_and_devices_tools_relay_end_to_end() {
824        use crate::transport::relay::{ControlFrame, ServeOptions, bind, serve};
825        use std::io::Write;
826        use std::net::TcpStream;
827
828        let listener = bind("127.0.0.1:0").unwrap();
829        let addr = listener.local_addr().unwrap().to_string();
830        let opts = ServeOptions {
831            token: "tok".into(),
832            max_conns: Some(4),
833        };
834        std::thread::spawn(move || {
835            let _ = serve(&listener, &opts);
836        });
837
838        // Register a device by writing a Register frame directly.
839        let mut dev = TcpStream::connect(&addr).unwrap();
840        let reg = ControlFrame::Register {
841            v: 1,
842            device: "watch".into(),
843            platform: "watchos".into(),
844            token: "tok".into(),
845        };
846        let line = reg.json_line().unwrap();
847        dev.write_all(line.as_bytes()).unwrap();
848        dev.write_all(b"\n").unwrap();
849        dev.flush().unwrap();
850        std::thread::sleep(std::time::Duration::from_millis(200));
851
852        // send_tool casts through the relay and reports delivery.
853        let out = send_tool(&SendInput {
854            text: Some("hi".into()),
855            relay: Some(addr.clone()),
856            device: Some("watch".into()),
857            token: Some("tok".into()),
858            ..Default::default()
859        })
860        .unwrap();
861        assert!(out.cast);
862        assert_eq!(out.acked, Some(true));
863        assert_eq!(out.delivered, Some(1));
864
865        // devices_tool lists the connected device.
866        let dev_out = devices_tool(&DevicesInput {
867            relay: addr.clone(),
868            token: Some("tok".into()),
869        })
870        .unwrap();
871        assert_eq!(dev_out.devices, vec!["watch".to_owned()]);
872
873        drop(dev);
874    }
875
876    /// CI <-> self-update lockstep guard (bd-8339d5 + bd-7b8ba4).
877    ///
878    /// `omni update` uses `updatable-cli`'s default `TendrilStyle` strategy,
879    /// which downloads `<tool>-<version>-<target>.tar.gz`, verifies the
880    /// `<tool>-<version>-<target>.sha256` sidecar, and extracts the binary from
881    /// `<tool>-<version>-<target>/<tool>`. The release workflow MUST publish
882    /// exactly that layout or self-update silently breaks at the download/
883    /// extract step. Pin the contract here so any CI edit that renames or
884    /// re-lays-out the release assets fails fast in unit tests instead of
885    /// shipping a release `omni update` cannot consume.
886    #[test]
887    fn release_workflow_matches_self_update_asset_contract() {
888        // The asset prefix is the on-disk tool name the updater installs as.
889        assert_eq!(TOOL_NAME, "omni", "asset prefix is the updater tool name");
890
891        let workflow = include_str!("../.github/workflows/release.yml");
892        for needle in [
893            // archive root == <tool>-<version>-<target>
894            r#"root="omni-$VERSION-$TARGET""#,
895            // binary lives at <root>/<tool> (TendrilStyle binary_in_archive);
896            // source resolves from CARGO_TARGET_DIR (shared build-cache) or ./target
897            r#"cp "${CARGO_TARGET_DIR:-target}/release/omni" "$root/omni""#,
898            // archive == <root>.tar.gz containing the <root>/ dir
899            r#"tar -czf "dist/$root.tar.gz" "$root""#,
900            // checksum sidecar == <root>.sha256 (verify_sha256 reads first token)
901            r#"dist/$root.sha256"#,
902        ] {
903            assert!(
904                workflow.contains(needle),
905                "release.yml must keep the updatable-cli TendrilStyle asset layout; missing: {needle}"
906            );
907        }
908    }
909
910    /// Feedback routing identity (bd-afbb18). omni's feedback events must be
911    /// tagged for the omni-cli board — component `omni`, project `omni-cli` — so
912    /// the `type: bead` caco webhook (`bead.project: omni-cli`) files them on the
913    /// right board. Pin it so a config-default regression cannot silently
914    /// misroute feedback to the wrong project or drop the component tag.
915    #[test]
916    fn feedback_routes_to_omni_cli_board() {
917        let cfg = feedback_config();
918        assert_eq!(
919            cfg.component.as_deref(),
920            Some("omni"),
921            "feedback component must identify omni"
922        );
923        // Mirrors feedback_config()'s resolution: CACOPHONY_PROJECT or the
924        // omni-cli default — robust to the ambient env the test runs in.
925        let expected_project =
926            std::env::var("CACOPHONY_PROJECT").unwrap_or_else(|_| "omni-cli".to_owned());
927        assert_eq!(cfg.project.as_deref(), Some(expected_project.as_str()));
928    }
929
930    /// The base-URL join (bd-bc5976) must produce `<base>/<project>` for Harry's
931    /// canonical global-hook namespace, tolerating a trailing slash on the base
932    /// and a leading slash on the sub-path so omni feedback lands on its OWN hook
933    /// sub-path (e.g. `.../hooks/global/omni-cli`) instead of a shared board.
934    #[test]
935    fn feedback_webhook_url_joins_base_and_project_subpath() {
936        assert_eq!(
937            feedback_webhook_url("http://helsinki:11300/hooks/global", "omni-cli"),
938            "http://helsinki:11300/hooks/global/omni-cli"
939        );
940        assert_eq!(
941            feedback_webhook_url("http://helsinki:11300/hooks/global/", "/omni-cli"),
942            "http://helsinki:11300/hooks/global/omni-cli"
943        );
944    }
945}