1use 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
39pub const UPDATE_REPO_SLUG: &str = "harryaskham/omni-cli";
41
42pub const TOOL_NAME: &str = "omni";
45
46pub use omni_sdk::AppError;
48
49#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
55pub struct StatusInput {}
56
57#[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#[must_use]
72pub fn status(input: &StatusInput) -> StatusOutput {
73 status_with(input, &feedback_config())
74}
75
76#[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#[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#[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#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
142pub struct DoctorInput {
143 #[serde(default)]
147 pub probe: bool,
148}
149
150#[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 pub healthy: bool,
160}
161
162fn 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
170const PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(500);
172
173fn strip_relay_scheme(url: &str) -> &str {
176 url.strip_prefix("tcp://")
177 .or_else(|| url.strip_prefix("tcps://"))
178 .unwrap_or(url)
179}
180
181fn 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
203fn 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
221fn 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) .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#[must_use]
257pub fn doctor(input: &DoctorInput) -> DoctorReport {
258 doctor_with(None, input.probe)
259}
260
261#[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 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 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 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 checks.push(DoctorCheck::ok(
322 "update",
323 format!("self-update from {UPDATE_REPO_SLUG}"),
324 ));
325
326 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 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#[derive(Debug, Default, Clone)]
396pub struct AppContext;
397
398#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
403pub struct SendInput {
404 #[serde(default)]
406 pub text: Option<String>,
407 #[serde(default)]
409 pub key: Option<String>,
410 #[serde(default)]
412 pub combo: Option<String>,
413 #[serde(default)]
415 pub scroll: Option<i64>,
416 #[serde(default)]
419 pub relay: Option<String>,
420 #[serde(default)]
422 pub device: Option<String>,
423 #[serde(default)]
426 pub token: Option<String>,
427}
428
429#[derive(Debug, Clone, Serialize, JsonSchema)]
431pub struct SendOutput {
432 pub wire: String,
434 pub cast: bool,
436 #[serde(skip_serializing_if = "Option::is_none")]
438 pub acked: Option<bool>,
439 #[serde(skip_serializing_if = "Option::is_none")]
442 pub delivered: Option<usize>,
443}
444
445fn 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
469pub 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#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
504pub struct DevicesInput {
505 pub relay: String,
507 #[serde(default)]
509 pub token: Option<String>,
510}
511
512#[derive(Debug, Clone, Serialize, JsonSchema)]
514pub struct DevicesOutput {
515 pub devices: Vec<String>,
517}
518
519pub 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#[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 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 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 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::register_update_tool(&mut router, |_ctx: &AppContext| updater_config());
575
576 feedback_cli::register_feedback_tools(&mut router, |_ctx: &AppContext| feedback_config());
580
581 router
582}
583
584#[must_use]
592pub fn updater_config() -> updatable_cli::UpdaterConfig {
593 updater_config_with(std::env::var("GITHUB_TOKEN").ok())
594}
595
596#[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#[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 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#[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 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 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 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 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 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 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(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 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 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 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 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 #[test]
887 fn release_workflow_matches_self_update_asset_contract() {
888 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 r#"root="omni-$VERSION-$TARGET""#,
895 r#"cp "${CARGO_TARGET_DIR:-target}/release/omni" "$root/omni""#,
898 r#"tar -czf "dist/$root.tar.gz" "$root""#,
900 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 #[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 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 #[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}