diff --git a/desktop/app/src-tauri/Cargo.lock b/desktop/app/src-tauri/Cargo.lock index 3cd3cad4..66bf839e 100644 --- a/desktop/app/src-tauri/Cargo.lock +++ b/desktop/app/src-tauri/Cargo.lock @@ -32,6 +32,12 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -47,6 +53,12 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "atk" version = "0.18.2" @@ -242,6 +254,21 @@ dependencies = [ "toml 0.9.11+spec-1.1.0", ] +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.53" @@ -313,6 +340,20 @@ dependencies = [ "memchr", ] +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "convert_case" version = "0.4.0" @@ -402,6 +443,31 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.10.0", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -460,14 +526,38 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + [[package]] name = "darling" version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.114", ] [[package]] @@ -484,17 +574,34 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.114", +] + [[package]] name = "darling_macro" version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ - "darling_core", + "darling_core 0.21.3", "quote", "syn 2.0.114", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "deranged" version = "0.5.5" @@ -637,6 +744,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + [[package]] name = "embed-resource" version = "3.0.6" @@ -674,6 +787,16 @@ dependencies = [ "typeid", ] +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "fdeflate" version = "0.3.7" @@ -726,6 +849,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foreign-types" version = "0.5.0" @@ -1150,6 +1279,17 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -1435,6 +1575,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "infer" version = "0.19.0" @@ -1444,6 +1593,19 @@ dependencies = [ "cfb", ] +[[package]] +name = "instability" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf9fed6d91cfb734e7476a06bde8300a1b94e217e1b523b6f0cd1a01998c71d" +dependencies = [ + "darling 0.20.11", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -1460,6 +1622,15 @@ dependencies = [ "serde", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.17" @@ -1623,6 +1794,12 @@ dependencies = [ "redox_syscall 0.7.0", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "litemap" version = "0.8.1" @@ -1644,6 +1821,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "mac" version = "0.1.1" @@ -1719,6 +1905,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", + "log", "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] @@ -2067,11 +2254,15 @@ dependencies = [ "filetime", "flate2", "libc", + "ratatui", "serde", "serde_json", "tauri", "tauri-build", "tauri-plugin-dialog", + "tungstenite", + "ureq", + "vt100", "walkdir", ] @@ -2123,6 +2314,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2510,6 +2707,27 @@ dependencies = [ "rand_core 0.5.1", ] +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags 2.10.0", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -2662,6 +2880,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -2900,7 +3131,7 @@ version = "3.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" dependencies = [ - "darling", + "darling 0.21.3", "proc-macro2", "quote", "syn 2.0.114", @@ -2938,6 +3169,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -2955,6 +3197,37 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.8" @@ -3049,6 +3322,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "string_cache" version = "0.8.9" @@ -3080,6 +3359,28 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.114", +] + [[package]] name = "swift-rs" version = "1.0.7" @@ -3782,6 +4083,25 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.5", + "sha1", + "thiserror 1.0.69", + "url", + "utf-8", +] + [[package]] name = "typeid" version = "1.0.3" @@ -3847,6 +4167,43 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64 0.22.1", + "log", + "once_cell", + "serde", + "serde_json", + "url", +] + [[package]] name = "url" version = "2.5.8" @@ -3884,6 +4241,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.19.0" @@ -3928,6 +4291,39 @@ dependencies = [ "libc", ] +[[package]] +name = "vt100" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84cd863bf0db7e392ba3bd04994be3473491b31e66340672af5d11943c6274de" +dependencies = [ + "itoa", + "log", + "unicode-width 0.1.14", + "vte", +] + +[[package]] +name = "vte" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5022b5fbf9407086c180e9557be968742d839e68346af7792b8592489732197" +dependencies = [ + "arrayvec", + "utf8parse", + "vte_generate_state_changes", +] + +[[package]] +name = "vte_generate_state_changes" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e369bee1b05d510a7b4ed645f5faa90619e05437111783ea5848f28d97d3c2e" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "walkdir" version = "2.5.0" diff --git a/desktop/app/src-tauri/Cargo.toml b/desktop/app/src-tauri/Cargo.toml index 80da64ea..93e15b82 100644 --- a/desktop/app/src-tauri/Cargo.toml +++ b/desktop/app/src-tauri/Cargo.toml @@ -5,6 +5,17 @@ edition = "2021" description = "Orcabot Desktop shell" license = "UNLICENSED" +# The Tauri GUI app (default binary from src/main.rs). +[[bin]] +name = "orcabot-desktop" +path = "src/main.rs" + +# The `orcabot` CLI: launches the desktop services headlessly and drives them from +# the terminal (exec/status/agents/integrations). Does not reference Tauri. +[[bin]] +name = "orcabot" +path = "src/bin/orcabot.rs" + [build-dependencies] tauri-build = { version = "2", features = [] } @@ -18,6 +29,14 @@ libc = "0.2" flate2 = "1" filetime = "0.2" ctrlc = "3" +# Minimal blocking HTTP client for the CLI (localhost only — no TLS needed). +ureq = { version = "2", default-features = false, features = ["json"] } +# Terminal UI for the interactive `orcabot` session (re-exports crossterm). +ratatui = "0.29" +# WebSocket client for streaming a terminal's PTY (ws:// localhost — no TLS). +tungstenite = { version = "0.21", default-features = false, features = ["handshake"] } +# VT/ANSI parser to render PTY output (agents draw full-screen TUIs). +vt100 = "0.15" # Note: virt-fwk for native Virtualization.framework on macOS is not yet # stable enough. The current implementation uses QEMU as a fallback. diff --git a/desktop/app/src-tauri/src/bin/orcabot.rs b/desktop/app/src-tauri/src/bin/orcabot.rs new file mode 100644 index 00000000..a1f1928c --- /dev/null +++ b/desktop/app/src-tauri/src/bin/orcabot.rs @@ -0,0 +1,1749 @@ +// REVISION: orcabot-cli-v5-terminal-pane +// +// `orcabot` — command-line control for the Orcabot desktop stack. +// +// It launches the desktop services *headlessly* (no GUI window) as a background +// process, then drives them from the terminal: run commands in the sandbox VM, +// check status, and (incrementally) initiate agents and connections — i.e. the +// same things the in-app chat does, from the outside. +// +// Transport: the desktop app exposes the control plane on 127.0.0.1:8787 and the +// sandbox on 127.0.0.1:8080 (host loopback). `exec` uses the sandbox /debug/exec +// endpoint, authenticated with the per-boot token the VM writes to its console. + +use std::collections::HashMap; +use std::fs::{self, File}; +use std::io::Write; +use std::os::unix::process::CommandExt; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; +use ratatui::layout::{Constraint, Layout}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, List, ListItem, Paragraph}; +use ratatui::{DefaultTerminal, Frame}; + +use tungstenite::Message; + +const CONTROLPLANE_PORT: u16 = 8787; +const SANDBOX_PORT: u16 = 8080; +const FRONTEND_PORT: u16 = 8788; +const VZ_CONSOLE_LOG: &str = "/tmp/vz-console.log"; +const REVISION: &str = "orcabot-cli-v5-terminal-pane"; + +fn main() { + let args: Vec = std::env::args().collect(); + // Bare `orcabot` opens the interactive TUI — the primary interface. + let cmd = args.get(1).map(String::as_str).unwrap_or("tui"); + let rest = &args[args.len().min(2)..]; + + let code = match cmd { + "tui" | "ui" => cmd_tui(), + "ls" | "components" => cmd_ls(rest), + "tail" => cmd_tail(rest), + "new" | "create" => cmd_new(rest), + "connect" => cmd_connect(rest), + "attach" => cmd_attach(rest), + "detach" => cmd_detach(rest), + "up" | "start" => cmd_up(rest), + "down" | "stop" => cmd_down(), + "status" => cmd_status(), + "exec" => cmd_exec(rest), + "help" | "-h" | "--help" => { + print_help(); + 0 + } + "version" | "--version" => { + println!("orcabot {}", REVISION); + 0 + } + other => { + eprintln!("orcabot: unknown command '{}'\n", other); + print_help(); + 2 + } + }; + std::process::exit(code); +} + +fn print_help() { + println!( + "orcabot — control the Orcabot desktop stack from the terminal\n\n\ + USAGE:\n orcabot [args]\n\n\ + COMMANDS:\n\ + \x20 (no args) / tui Open the interactive TUI (component list + command line)\n\ + \x20 ls Print the dashboard's components + status (non-interactive)\n\ + \x20 up [--timeout N] Launch the stack headlessly (background) and wait until ready\n\ + \x20 down Stop the headless stack\n\ + \x20 status Show service health (control plane, sandbox, frontend)\n\ + \x20 exec Run a shell command inside the sandbox VM\n\ + \x20 version Print CLI revision\n\n\ + EXAMPLES:\n\ + \x20 orcabot up && orcabot # bring up the stack, then open the TUI\n\ + \x20 orcabot ls\n\ + \x20 orcabot exec 'ip -4 addr show eth0'" + ); +} + +// ---- paths / state ------------------------------------------------------- + +fn data_dir() -> PathBuf { + let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); + let p = if cfg!(target_os = "macos") { + PathBuf::from(home).join("Library/Application Support/com.orcabot.desktop") + } else { + PathBuf::from(home).join(".local/share/com.orcabot.desktop") + }; + let _ = fs::create_dir_all(&p); + p +} + +fn pid_file() -> PathBuf { + data_dir().join("orcabot-cli.pid") +} + +fn headless_log() -> PathBuf { + data_dir().join("headless.log") +} + +/// Resolve the desktop binary, which sits next to this CLI binary. +fn desktop_binary() -> Option { + let exe = std::env::current_exe().ok()?; + let dir = exe.parent()?; + let cand = dir.join("orcabot-desktop"); + if cand.exists() { + Some(cand) + } else { + None + } +} + +// ---- http helpers -------------------------------------------------------- + +fn agent(timeout: Duration) -> ureq::Agent { + ureq::AgentBuilder::new().timeout(timeout).build() +} + +/// GET, returning (status, body) for any HTTP response, None on connection error. +fn http_get(url: &str, timeout: Duration) -> Option<(u16, String)> { + match agent(timeout).get(url).call() { + Ok(resp) => Some((resp.status(), resp.into_string().unwrap_or_default())), + Err(ureq::Error::Status(code, resp)) => { + Some((code, resp.into_string().unwrap_or_default())) + } + Err(_) => None, + } +} + +fn controlplane_healthy() -> bool { + matches!( + http_get( + &format!("http://127.0.0.1:{}/health", CONTROLPLANE_PORT), + Duration::from_secs(2) + ), + Some((200, _)) + ) +} + +fn sandbox_health() -> Option { + match http_get( + &format!("http://127.0.0.1:{}/health", SANDBOX_PORT), + Duration::from_secs(2), + ) { + Some((200, body)) => Some(body), + _ => None, + } +} + +// ---- commands ------------------------------------------------------------ + +fn cmd_up(rest: &[String]) -> i32 { + let mut timeout_secs = 150u64; + let mut i = 0; + while i < rest.len() { + if rest[i] == "--timeout" { + if let Some(v) = rest.get(i + 1).and_then(|s| s.parse::().ok()) { + timeout_secs = v; + } + i += 2; + } else { + i += 1; + } + } + + if controlplane_healthy() { + println!("orcabot: stack already running (control plane healthy on :{CONTROLPLANE_PORT})"); + if sandbox_health().is_some() { + println!("orcabot: sandbox healthy on :{SANDBOX_PORT}"); + } else { + println!("orcabot: sandbox still starting…"); + } + return 0; + } + + let bin = match desktop_binary() { + Some(b) => b, + None => { + eprintln!("orcabot: could not find the orcabot-desktop binary next to this CLI"); + return 1; + } + }; + + let log_path = headless_log(); + let log = match File::create(&log_path) { + Ok(f) => f, + Err(e) => { + eprintln!("orcabot: cannot open log {}: {e}", log_path.display()); + return 1; + } + }; + let log_err = match log.try_clone() { + Ok(f) => f, + Err(e) => { + eprintln!("orcabot: log clone failed: {e}"); + return 1; + } + }; + + println!("orcabot: launching headless stack ({})…", bin.display()); + let mut command = Command::new(&bin); + command + .env("ORCABOT_DESKTOP_HEADLESS", "1") + .env("VZ_CONSOLE_DIRECT", "1") + .stdin(Stdio::null()) + .stdout(Stdio::from(log)) + .stderr(Stdio::from(log_err)); + // Detach into its own session so it survives this CLI process exiting. + unsafe { + command.pre_exec(|| { + libc::setsid(); + Ok(()) + }); + } + let child = match command.spawn() { + Ok(c) => c, + Err(e) => { + eprintln!("orcabot: failed to launch desktop binary: {e}"); + return 1; + } + }; + let pid = child.id(); + let _ = fs::write(pid_file(), pid.to_string()); + println!("orcabot: started (pid {pid}), logs at {}", log_path.display()); + + // Wait for readiness. + let deadline = Instant::now() + Duration::from_secs(timeout_secs); + let mut cp_ready = false; + print!("orcabot: waiting for services"); + let _ = std::io::stdout().flush(); + while Instant::now() < deadline { + if !cp_ready && controlplane_healthy() { + cp_ready = true; + println!("\norcabot: control plane ready (:{CONTROLPLANE_PORT})"); + print!("orcabot: waiting for sandbox VM"); + let _ = std::io::stdout().flush(); + } + if cp_ready { + if let Some(h) = sandbox_health() { + println!("\norcabot: sandbox ready — {}", h.trim()); + println!("orcabot: stack up. Try: orcabot exec 'ip -4 addr show eth0'"); + return 0; + } + } + print!("."); + let _ = std::io::stdout().flush(); + std::thread::sleep(Duration::from_secs(2)); + } + eprintln!("\norcabot: timed out after {timeout_secs}s. Check {}", headless_log().display()); + 1 +} + +fn cmd_down() -> i32 { + let pid = fs::read_to_string(pid_file()) + .ok() + .and_then(|s| s.trim().parse::().ok()); + let Some(pid) = pid else { + eprintln!("orcabot: no pid file — stack not started by this CLI (nothing to stop)"); + return 1; + }; + // SIGINT triggers the desktop app's ctrlc handler → clean shutdown (stops VM). + let rc = unsafe { libc::kill(pid, libc::SIGINT) }; + if rc != 0 { + eprintln!("orcabot: process {pid} not running (already stopped?)"); + let _ = fs::remove_file(pid_file()); + return 1; + } + print!("orcabot: stopping (pid {pid})"); + let _ = std::io::stdout().flush(); + for _ in 0..20 { + if unsafe { libc::kill(pid, 0) } != 0 { + println!("\norcabot: stopped."); + let _ = fs::remove_file(pid_file()); + return 0; + } + print!("."); + let _ = std::io::stdout().flush(); + std::thread::sleep(Duration::from_millis(500)); + } + eprintln!("\norcabot: still running after 10s; sending SIGKILL"); + unsafe { libc::kill(pid, libc::SIGKILL) }; + let _ = fs::remove_file(pid_file()); + 0 +} + +fn cmd_status() -> i32 { + let cp = controlplane_healthy(); + let sb = sandbox_health(); + let fe = matches!( + http_get( + &format!("http://127.0.0.1:{}/", FRONTEND_PORT), + Duration::from_secs(2) + ), + Some((code, _)) if code < 500 + ); + println!("control plane (:{CONTROLPLANE_PORT}): {}", if cp { "ready" } else { "down" }); + println!( + "sandbox (:{SANDBOX_PORT}): {}", + match &sb { + Some(h) => h.trim().to_string(), + None => "down".to_string(), + } + ); + println!("frontend (:{FRONTEND_PORT}): {}", if fe { "ready" } else { "down" }); + if cp && sb.is_some() { + 0 + } else { + 1 + } +} + +fn read_debug_token() -> Option { + let content = fs::read_to_string(VZ_CONSOLE_LOG).ok()?; + let line = content + .lines() + .rev() + .find(|l| l.contains("debug-exec] auth token:"))?; + let tok: String = line + .rsplit("auth token:") + .next()? + .trim() + .chars() + .filter(|c| c.is_ascii_hexdigit()) + .collect(); + if tok.len() >= 32 { + Some(tok) + } else { + None + } +} + +fn cmd_exec(rest: &[String]) -> i32 { + if rest.is_empty() { + eprintln!("orcabot exec: needs a command, e.g. orcabot exec 'ip addr'"); + return 2; + } + if sandbox_health().is_none() { + eprintln!("orcabot: sandbox not reachable on :{SANDBOX_PORT} — run `orcabot up` first"); + return 1; + } + let token = match read_debug_token() { + Some(t) => t, + None => { + eprintln!( + "orcabot: could not read debug-exec token from {VZ_CONSOLE_LOG}.\n\ + The stack must have been launched with VZ_CONSOLE_DIRECT=1 (orcabot up does this)." + ); + return 1; + } + }; + let command = rest.join(" "); + let body = serde_json::json!({ "cmd": command, "timeout_ms": 60000 }); + let resp = agent(Duration::from_secs(65)) + .post(&format!("http://127.0.0.1:{}/debug/exec", SANDBOX_PORT)) + .set("X-Debug-Exec-Token", &token) + .set("Content-Type", "application/json") + .send_json(body); + + let json: serde_json::Value = match resp { + Ok(r) => r.into_json().unwrap_or_default(), + Err(ureq::Error::Status(code, r)) => { + eprintln!( + "orcabot exec: HTTP {code}: {}", + r.into_string().unwrap_or_default().trim() + ); + return 1; + } + Err(e) => { + eprintln!("orcabot exec: request failed: {e}"); + return 1; + } + }; + + if let Some(s) = json.get("stdout").and_then(|v| v.as_str()) { + print!("{s}"); + } + if let Some(s) = json.get("stderr").and_then(|v| v.as_str()) { + if !s.is_empty() { + eprint!("{s}"); + } + } + json.get("exit_code").and_then(|v| v.as_i64()).unwrap_or(0) as i32 +} + +// =========================================================================== +// Control-plane client (the engine) — dev-auth, components, mutations. +// =========================================================================== + +const DEV_USER: &str = "dev-desktop"; + +fn cp_call(method: &str, path: &str, body: Option) -> Result { + let url = format!("http://127.0.0.1:{}{}", CONTROLPLANE_PORT, path); + let req = agent(Duration::from_secs(15)) + .request(method, &url) + .set("X-User-ID", DEV_USER) + .set("Content-Type", "application/json"); + let resp = match body { + Some(b) => req.send_json(b), + None => req.call(), + }; + match resp { + Ok(r) => Ok(r.into_json().unwrap_or(serde_json::Value::Null)), + Err(ureq::Error::Status(code, r)) => Err(format!( + "HTTP {code}: {}", + r.into_string().unwrap_or_default().trim() + )), + Err(e) => Err(e.to_string()), + } +} + +/// (id, name) for each dashboard the dev user can see. +fn list_dashboards() -> Result, String> { + let v = cp_call("GET", "/dashboards", None)?; + let arr = v.get("dashboards").and_then(|x| x.as_array()).cloned().unwrap_or_default(); + Ok(arr + .iter() + .filter_map(|d| { + let id = d.get("id")?.as_str()?.to_string(); + let name = d + .get("name") + .or_else(|| d.get("title")) + .and_then(|x| x.as_str()) + .unwrap_or("(untitled)") + .to_string(); + Some((id, name)) + }) + .collect()) +} + +struct Component { + id: String, + kind: String, + label: String, + status: String, +} + +fn is_integration(kind: &str) -> bool { + matches!( + kind, + "gmail" | "calendar" | "contacts" | "sheets" | "forms" | "twitter" | "outlook" + | "slack" | "discord" | "telegram" | "whatsapp" | "teams" | "matrix" | "google_chat" + | "github" | "drive" + ) +} + +fn label_for(item: &serde_json::Value, content: &str) -> String { + // Terminals (and some blocks) store JSON content with a human "name". + if let Ok(j) = serde_json::from_str::(content) { + if let Some(n) = j.get("name").and_then(|x| x.as_str()) { + if !n.is_empty() { + return n.to_string(); + } + } + } + if let Some(t) = item + .get("metadata") + .and_then(|m| m.get("title")) + .and_then(|x| x.as_str()) + { + if !t.is_empty() { + return t.to_string(); + } + } + let first = content.lines().next().unwrap_or("").trim(); + if first.is_empty() { + "(empty)".to_string() + } else if first.len() > 44 { + format!("{}…", &first[..44]) + } else { + first.to_string() + } +} + +fn get_components(dash_id: &str) -> Result, String> { + let v = cp_call("GET", &format!("/dashboards/{}", dash_id), None)?; + let items = v.get("items").and_then(|x| x.as_array()).cloned().unwrap_or_default(); + let sessions = v.get("sessions").and_then(|x| x.as_array()).cloned().unwrap_or_default(); + + let mut sess: HashMap = HashMap::new(); + for s in &sessions { + if let (Some(it), Some(st)) = ( + s.get("itemId").and_then(|x| x.as_str()), + s.get("status").and_then(|x| x.as_str()), + ) { + sess.insert(it.to_string(), st.to_string()); + } + } + + let mut out = Vec::new(); + for it in &items { + let id = it.get("id").and_then(|x| x.as_str()).unwrap_or("").to_string(); + let kind = it.get("type").and_then(|x| x.as_str()).unwrap_or("?").to_string(); + let content = it.get("content").and_then(|x| x.as_str()).unwrap_or(""); + let label = label_for(it, content); + let status = if kind == "terminal" { + match sess.get(&id).map(String::as_str) { + Some("active") => "running", + Some("creating") => "starting", + Some(other) => other, + None => "idle", + } + .to_string() + } else if is_integration(&kind) { + "attached".to_string() + } else { + "-".to_string() + }; + out.push(Component { id, kind, label, status }); + } + Ok(out) +} + +/// Map an agent name to (display name, boot command). Empty boot = plain shell. +fn agent_boot(agent: &str) -> (String, String) { + match agent { + "claude" | "claude-code" => ("Claude Code".into(), "claude".into()), + "gemini" => ("Gemini".into(), "gemini".into()), + "codex" => ("Codex".into(), "codex".into()), + "shell" | "" => ("Shell".into(), String::new()), + other => (other.to_string(), other.to_string()), + } +} + +/// Create a terminal component running the given agent, then start its session +/// (boots the agent in a PTY in the sandbox). Returns the new item id. +fn create_terminal(dash_id: &str, agent: &str) -> Result { + let (name, boot) = agent_boot(agent); + let content = serde_json::json!({ + "name": name, + "bootCommand": boot, + "skipApprovals": true, + "subagentIds": [], + "skillIds": [], + }) + .to_string(); + let resp = cp_call( + "POST", + &format!("/dashboards/{}/items", dash_id), + Some(serde_json::json!({ "type": "terminal", "content": content })), + )?; + let item_id = resp + .get("item") + .and_then(|i| i.get("id")) + .and_then(|x| x.as_str()) + .ok_or("create item: no id in response")? + .to_string(); + // Start the session — provisions the PTY and runs the boot command. + cp_call( + "POST", + &format!("/dashboards/{}/items/{}/session", dash_id, item_id), + Some(serde_json::json!({})), + )?; + Ok(item_id) +} + +fn create_note(dash_id: &str, text: &str) -> Result { + let resp = cp_call( + "POST", + &format!("/dashboards/{}/items", dash_id), + Some(serde_json::json!({ "type": "note", "content": text })), + )?; + Ok(resp + .get("item") + .and_then(|i| i.get("id")) + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_string()) +} + +fn first_or_named_dash(named: Option) -> Result { + if let Some(id) = named { + return Ok(id); + } + list_dashboards()? + .into_iter() + .next() + .map(|(id, _)| id) + .ok_or_else(|| "no dashboards — create one first".to_string()) +} + +// ---- new (non-interactive create, also reused by the TUI) ----------------- + +fn cmd_new(rest: &[String]) -> i32 { + if !controlplane_healthy() { + eprintln!("orcabot: control plane not reachable — run `orcabot up` first"); + return 1; + } + // Pull out `--dash `; the remainder is positional. + let mut dash: Option = None; + let mut pos: Vec = Vec::new(); + let mut it = rest.iter(); + while let Some(a) = it.next() { + if a == "--dash" { + dash = it.next().cloned(); + } else { + pos.push(a.clone()); + } + } + match pos.first().map(String::as_str) { + Some("terminal") => { + let agent = pos.get(1).map(String::as_str).unwrap_or("claude"); + let did = match first_or_named_dash(dash) { + Ok(d) => d, + Err(e) => { + eprintln!("orcabot: {e}"); + return 1; + } + }; + match create_terminal(&did, agent) { + Ok(id) => { + println!("created {agent} terminal {id}"); + 0 + } + Err(e) => { + eprintln!("orcabot: {e}"); + 1 + } + } + } + Some("note") => { + let text = pos[1..].join(" "); + let did = match first_or_named_dash(dash) { + Ok(d) => d, + Err(e) => { + eprintln!("orcabot: {e}"); + return 1; + } + }; + match create_note(&did, &text) { + Ok(id) => { + println!("created note {id}"); + 0 + } + Err(e) => { + eprintln!("orcabot: {e}"); + 1 + } + } + } + _ => { + eprintln!("usage: orcabot new terminal [claude|gemini|codex|shell] [--dash ]"); + eprintln!(" orcabot new note [--dash ]"); + 2 + } + } +} + +// ---- PTY streaming (terminal I/O) ----------------------------------------- + +/// Resolve a terminal component (item id) to its (control-plane session id, pty id). +fn session_for_item(dash: &str, item_id: &str) -> Result<(String, String), String> { + let v = cp_call("GET", &format!("/dashboards/{}", dash), None)?; + let sessions = v.get("sessions").and_then(|x| x.as_array()).cloned().unwrap_or_default(); + for s in sessions { + if s.get("itemId").and_then(|x| x.as_str()) == Some(item_id) { + let sid = s.get("id").and_then(|x| x.as_str()).unwrap_or(""); + let pty = s.get("ptyId").and_then(|x| x.as_str()).unwrap_or(""); + if !sid.is_empty() && !pty.is_empty() { + return Ok((sid.to_string(), pty.to_string())); + } + } + } + Err("that terminal has no running session yet (start it first)".to_string()) +} + +/// Open the PTY WebSocket through the control plane (dev-auth via X-User-ID). +/// Returns a connected, blocking WebSocket with a short read timeout so callers +/// can poll without blocking forever. +fn open_pty_ws( + session_id: &str, + pty_id: &str, + read_timeout: Duration, +) -> Result>, String> { + use tungstenite::client::IntoClientRequest; + // WS auth goes via the user_id query param (browsers can't set headers on a WS + // handshake, so that's how the frontend authenticates). Also send an allowed + // Origin + the X-User-ID header as belt-and-suspenders. + let url = format!( + "ws://127.0.0.1:{}/sessions/{}/ptys/{}/ws?user_id={}", + CONTROLPLANE_PORT, session_id, pty_id, DEV_USER + ); + let mut req = url.into_client_request().map_err(|e| e.to_string())?; + req.headers_mut().insert( + "X-User-ID", + tungstenite::http::HeaderValue::from_static(DEV_USER), + ); + req.headers_mut().insert( + "Origin", + tungstenite::http::HeaderValue::from_static("http://localhost:8788"), + ); + let (ws, _resp) = tungstenite::connect(req).map_err(|e| format!("ws connect: {e}"))?; + if let tungstenite::stream::MaybeTlsStream::Plain(s) = ws.get_ref() { + let _ = s.set_read_timeout(Some(read_timeout)); + } + Ok(ws) +} + +fn is_timeout(e: &tungstenite::Error) -> bool { + matches!(e, tungstenite::Error::Io(io) + if io.kind() == std::io::ErrorKind::WouldBlock || io.kind() == std::io::ErrorKind::TimedOut) +} + +/// Headless verification of the PTY stream: connect and print whatever the +/// terminal emits for `secs` seconds. Proves WS auth + routing + streaming. +fn cmd_tail(rest: &[String]) -> i32 { + if !controlplane_healthy() { + eprintln!("orcabot: run `orcabot up` first"); + return 1; + } + let (pos, dash) = split_dash_flag(rest); + let Some(item) = pos.first() else { + eprintln!("usage: orcabot tail [--dash ] [--secs N]"); + return 2; + }; + let mut secs = 5u64; + if let Some(i) = pos.iter().position(|a| a == "--secs") { + if let Some(v) = pos.get(i + 1).and_then(|s| s.parse::().ok()) { + secs = v; + } + } + // --screen feeds the stream through vt100 and prints the rendered grid at the + // end (verifies the exact render pipeline the TUI uses), instead of raw bytes. + let screen_mode = pos.iter().any(|a| a == "--screen"); + // --send : take control and type + Enter (verifies the input path). + let send_text = pos + .iter() + .position(|a| a == "--send") + .and_then(|i| pos.get(i + 1).cloned()); + let did = match dash_for_terminal(item, dash) { + Ok(d) => d, + Err(e) => { + eprintln!("orcabot: {e}"); + return 1; + } + }; + let (sid, pty) = match session_for_item(&did, item) { + Ok(p) => p, + Err(e) => { + eprintln!("orcabot: {e}"); + return 1; + } + }; + let mut ws = match open_pty_ws(&sid, &pty, Duration::from_millis(400)) { + Ok(w) => w, + Err(e) => { + eprintln!("orcabot: {e}"); + return 1; + } + }; + eprintln!("orcabot: connected to pty {} — streaming for {secs}s…", &pty[..pty.len().min(8)]); + // In screen mode, nudge the PTY to redraw so we capture a full frame. + let mut parser = vt100::Parser::new(24, 80, 0); + if screen_mode { + let _ = ws.send(tungstenite::Message::Text( + serde_json::json!({ "type": "resize", "cols": 80, "rows": 24 }).to_string(), + )); + } + if let Some(text) = &send_text { + let _ = ws.send(tungstenite::Message::Text( + serde_json::json!({ "type": "take_control" }).to_string(), + )); + std::thread::sleep(Duration::from_millis(300)); + let mut bytes = text.clone().into_bytes(); + bytes.push(b'\n'); + let _ = ws.send(tungstenite::Message::Binary(bytes)); + } + let deadline = Instant::now() + Duration::from_secs(secs); + let mut total = 0usize; + while Instant::now() < deadline { + match ws.read() { + Ok(tungstenite::Message::Binary(b)) => { + total += b.len(); + if screen_mode { + parser.process(&b); + } else { + print!("{}", String::from_utf8_lossy(&b)); + let _ = std::io::stdout().flush(); + } + } + Ok(tungstenite::Message::Text(t)) => { + if !screen_mode { + total += t.len(); + print!("{t}"); + let _ = std::io::stdout().flush(); + } + } + Ok(tungstenite::Message::Ping(p)) => { + let _ = ws.send(tungstenite::Message::Pong(p)); + } + Ok(tungstenite::Message::Close(_)) => break, + Ok(_) => {} + Err(e) if is_timeout(&e) => continue, + Err(e) => { + eprintln!("\norcabot: ws error: {e}"); + break; + } + } + } + let _ = ws.close(None); + if screen_mode { + println!("{}", parser.screen().contents()); + } + eprintln!("\norcabot: stream ended ({total} bytes received)"); + 0 +} + +// ---- live PTY session (background WS thread + vt100 screen) ---------------- + +enum WsOut { + Bin(Vec), + Text(String), +} + +/// A live attachment to a terminal's PTY: a background thread owns the WebSocket, +/// feeds binary output into a vt100 parser, and sends queued input/control frames. +struct PtySession { + item_id: String, + pty_short: String, + parser: Arc>, + status: Arc>, + stop: Arc, + out: mpsc::Sender, + rows: u16, + cols: u16, +} + +impl PtySession { + fn send_input(&self, bytes: Vec) { + let _ = self.out.send(WsOut::Bin(bytes)); + } + fn resize(&mut self, rows: u16, cols: u16) { + if rows == 0 || cols == 0 || (rows == self.rows && cols == self.cols) { + return; + } + self.rows = rows; + self.cols = cols; + if let Ok(mut p) = self.parser.lock() { + p.set_size(rows, cols); + } + let _ = self.out.send(WsOut::Text( + serde_json::json!({ "type": "resize", "cols": cols, "rows": rows }).to_string(), + )); + } + fn parser_status(&self) -> String { + self.status.lock().map(|s| s.clone()).unwrap_or_default() + } +} + +impl Drop for PtySession { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + } +} + +fn spawn_pty_session( + item_id: &str, + session_id: &str, + pty_id: &str, + rows: u16, + cols: u16, +) -> Result { + let mut ws = open_pty_ws(session_id, pty_id, Duration::from_millis(40))?; + // Take control so our keystrokes reach the PTY, and size it to our pane. + let _ = ws.send(Message::Text(serde_json::json!({ "type": "take_control" }).to_string())); + let _ = ws.send(Message::Text( + serde_json::json!({ "type": "resize", "cols": cols, "rows": rows }).to_string(), + )); + + let parser = Arc::new(Mutex::new(vt100::Parser::new(rows.max(1), cols.max(1), 0))); + let status = Arc::new(Mutex::new("connected".to_string())); + let stop = Arc::new(AtomicBool::new(false)); + let (tx, rx) = mpsc::channel::(); + + let (p, st, stt) = (parser.clone(), stop.clone(), status.clone()); + std::thread::spawn(move || { + loop { + if st.load(Ordering::Relaxed) { + break; + } + // Drain queued output (keystrokes / control frames). + while let Ok(m) = rx.try_recv() { + let r = match m { + WsOut::Bin(b) => ws.send(Message::Binary(b)), + WsOut::Text(t) => ws.send(Message::Text(t)), + }; + if r.is_err() { + break; + } + } + match ws.read() { + // Binary = PTY output → vt100. Text = JSON control frames → not rendered. + Ok(Message::Binary(b)) => { + if let Ok(mut pp) = p.lock() { + pp.process(&b); + } + } + Ok(Message::Ping(x)) => { + let _ = ws.send(Message::Pong(x)); + } + Ok(Message::Text(_)) => {} + Ok(Message::Close(_)) => { + if let Ok(mut s) = stt.lock() { + *s = "closed".into(); + } + break; + } + Ok(_) => {} + Err(e) if is_timeout(&e) => {} + Err(e) => { + if let Ok(mut s) = stt.lock() { + *s = format!("error: {e}"); + } + break; + } + } + } + let _ = ws.close(None); + }); + + Ok(PtySession { + item_id: item_id.to_string(), + pty_short: pty_id.chars().take(8).collect(), + parser, + status, + stop, + out: tx, + rows, + cols, + }) +} + +/// Encode a key event into the bytes a PTY expects. +fn key_to_bytes(code: KeyCode, mods: KeyModifiers) -> Option> { + let b = match code { + KeyCode::Char(c) => { + if mods.contains(KeyModifiers::CONTROL) { + let lc = c.to_ascii_lowercase(); + if lc.is_ascii_alphabetic() { + vec![lc as u8 - b'a' + 1] + } else { + return None; + } + } else { + c.to_string().into_bytes() + } + } + KeyCode::Enter => vec![b'\r'], + KeyCode::Backspace => vec![0x7f], + KeyCode::Tab => vec![b'\t'], + KeyCode::Esc => vec![0x1b], + KeyCode::Up => b"\x1b[A".to_vec(), + KeyCode::Down => b"\x1b[B".to_vec(), + KeyCode::Right => b"\x1b[C".to_vec(), + KeyCode::Left => b"\x1b[D".to_vec(), + KeyCode::Home => b"\x1b[H".to_vec(), + KeyCode::End => b"\x1b[F".to_vec(), + KeyCode::PageUp => b"\x1b[5~".to_vec(), + KeyCode::PageDown => b"\x1b[6~".to_vec(), + KeyCode::Delete => b"\x1b[3~".to_vec(), + _ => return None, + }; + Some(b) +} + +fn conv_color(c: vt100::Color) -> Color { + match c { + vt100::Color::Default => Color::Reset, + vt100::Color::Idx(i) => Color::Indexed(i), + vt100::Color::Rgb(r, g, b) => Color::Rgb(r, g, b), + } +} + +// ---- integrations: connect / attach / detach ------------------------------ + +/// Map a provider name to its OAuth connect sub-path under /integrations/. +fn connect_subpath(provider: &str) -> Option<&'static str> { + Some(match provider { + "gmail" => "google/gmail", + "drive" | "google_drive" => "google/drive", + "calendar" | "google_calendar" => "google/calendar", + "contacts" | "google_contacts" => "google/contacts", + "github" => "github", + "twitter" | "x" => "twitter", + "box" => "box", + "onedrive" => "onedrive", + _ => return None, + }) +} + +fn provider_matches(a: &str, b: &str) -> bool { + a == b + || (a == "drive" && b == "google_drive") + || (a == "google_drive" && b == "drive") +} + +/// Fetch the OAuth authorization URL for a provider (the connect route 302-redirects +/// to the provider; we capture the Location instead of following it). +fn connect_url(provider: &str) -> Result { + let sub = connect_subpath(provider) + .ok_or_else(|| format!("unknown provider '{provider}' (gmail|drive|calendar|github|twitter|box|onedrive)"))?; + let url = format!("http://127.0.0.1:{}/integrations/{}/connect", CONTROLPLANE_PORT, sub); + let ag = ureq::AgentBuilder::new() + .timeout(Duration::from_secs(10)) + .redirects(0) + .build(); + let loc_from = |code: u16, resp: ureq::Response| -> Result { + if (300..400).contains(&code) { + resp.header("location") + .map(|s| s.to_string()) + .ok_or_else(|| "redirect without Location header".to_string()) + } else { + let body = resp.into_string().unwrap_or_default(); + if body.contains("not configured") || body.contains("Connection failed") { + Err(format!( + "OAuth not configured for {provider} — relaunch with its *_CLIENT_ID/*_CLIENT_SECRET set" + )) + } else { + Err(format!("unexpected HTTP {code} (no redirect) connecting {provider}")) + } + } + }; + match ag.get(&url).set("X-User-ID", DEV_USER).call() { + Ok(resp) => loc_from(resp.status(), resp), + Err(ureq::Error::Status(code, resp)) => loc_from(code, resp), + Err(e) => Err(e.to_string()), + } +} + +/// Find the connected user_integration id for a provider on a terminal, if any. +fn find_user_integration(dash: &str, terminal: &str, provider: &str) -> Result, String> { + let v = cp_call( + "GET", + &format!("/dashboards/{}/terminals/{}/available-integrations", dash, terminal), + None, + )?; + let arr = v.get("integrations").and_then(|x| x.as_array()).cloned().unwrap_or_default(); + for e in arr { + let p = e.get("provider").and_then(|x| x.as_str()).unwrap_or(""); + if provider_matches(p, provider) { + if let Some(uid) = e.get("userIntegrationId").and_then(|x| x.as_str()) { + return Ok(Some(uid.to_string())); + } + } + } + Ok(None) +} + +/// Resolve a terminal component (dashboard item id) to its session's PTY id — +/// integrations key off the PTY id, not the item id. Requires a running session. +fn pty_for_item(dash: &str, item_id: &str) -> Result { + let v = cp_call("GET", &format!("/dashboards/{}", dash), None)?; + let sessions = v.get("sessions").and_then(|x| x.as_array()).cloned().unwrap_or_default(); + for s in sessions { + if s.get("itemId").and_then(|x| x.as_str()) == Some(item_id) { + if let Some(p) = s.get("ptyId").and_then(|x| x.as_str()) { + if !p.is_empty() { + return Ok(p.to_string()); + } + } + } + } + Err("that terminal has no running session/PTY yet (start it first)".to_string()) +} + +fn attach_integration(dash: &str, item_id: &str, provider: &str) -> Result<(), String> { + let pty = pty_for_item(dash, item_id)?; + match find_user_integration(dash, &pty, provider)? { + Some(uid) => { + cp_call( + "POST", + &format!("/dashboards/{}/terminals/{}/integrations", dash, pty), + Some(serde_json::json!({ "provider": provider, "userIntegrationId": uid })), + )?; + Ok(()) + } + None => Err(format!( + "{provider} isn't connected — run `connect {provider}` and authorize it first" + )), + } +} + +fn detach_integration(dash: &str, item_id: &str, provider: &str) -> Result<(), String> { + let pty = pty_for_item(dash, item_id)?; + cp_call( + "DELETE", + &format!("/dashboards/{}/terminals/{}/integrations/{}", dash, pty, provider), + None, + )?; + Ok(()) +} + +/// Find which dashboard a terminal/item id belongs to (so attach/detach can take +/// just the component id). Falls back to a provided --dash. +fn dash_for_terminal(terminal: &str, hint: Option) -> Result { + if let Some(h) = hint { + return Ok(h); + } + for (id, _) in list_dashboards()? { + if let Ok(cs) = get_components(&id) { + if cs.iter().any(|c| c.id == terminal) { + return Ok(id); + } + } + } + Err("could not find which dashboard that terminal belongs to (pass --dash )".to_string()) +} + +fn split_dash_flag(rest: &[String]) -> (Vec, Option) { + let mut dash = None; + let mut pos = Vec::new(); + let mut it = rest.iter(); + while let Some(a) = it.next() { + if a == "--dash" { + dash = it.next().cloned(); + } else { + pos.push(a.clone()); + } + } + (pos, dash) +} + +fn cmd_connect(rest: &[String]) -> i32 { + if !controlplane_healthy() { + eprintln!("orcabot: run `orcabot up` first"); + return 1; + } + let Some(provider) = rest.first() else { + eprintln!("usage: orcabot connect "); + return 2; + }; + match connect_url(provider) { + Ok(url) => { + println!("Open this URL in a browser to authorize {provider}:\n {url}"); + 0 + } + Err(e) => { + eprintln!("orcabot: {e}"); + 1 + } + } +} + +fn cmd_attach(rest: &[String]) -> i32 { + if !controlplane_healthy() { + eprintln!("orcabot: run `orcabot up` first"); + return 1; + } + let (pos, dash) = split_dash_flag(rest); + let (Some(terminal), Some(provider)) = (pos.first(), pos.get(1)) else { + eprintln!("usage: orcabot attach [--dash ]"); + return 2; + }; + let did = match dash_for_terminal(terminal, dash) { + Ok(d) => d, + Err(e) => { + eprintln!("orcabot: {e}"); + return 1; + } + }; + match attach_integration(&did, terminal, provider) { + Ok(()) => { + println!("attached {provider} to {terminal}"); + 0 + } + Err(e) => { + eprintln!("orcabot: {e}"); + 1 + } + } +} + +fn cmd_detach(rest: &[String]) -> i32 { + if !controlplane_healthy() { + eprintln!("orcabot: run `orcabot up` first"); + return 1; + } + let (pos, dash) = split_dash_flag(rest); + let (Some(terminal), Some(provider)) = (pos.first(), pos.get(1)) else { + eprintln!("usage: orcabot detach [--dash ]"); + return 2; + }; + let did = match dash_for_terminal(terminal, dash) { + Ok(d) => d, + Err(e) => { + eprintln!("orcabot: {e}"); + return 1; + } + }; + match detach_integration(&did, terminal, provider) { + Ok(()) => { + println!("detached {provider} from {terminal}"); + 0 + } + Err(e) => { + eprintln!("orcabot: {e}"); + 1 + } + } +} + +// ---- ls (non-interactive dump, used for headless verification) ------------ + +fn cmd_ls(_rest: &[String]) -> i32 { + if !controlplane_healthy() { + eprintln!("orcabot: control plane not reachable — run `orcabot up` first"); + return 1; + } + let dashboards = match list_dashboards() { + Ok(d) => d, + Err(e) => { + eprintln!("orcabot: {e}"); + return 1; + } + }; + if dashboards.is_empty() { + println!("(no dashboards yet — create one in the app or with the TUI)"); + return 0; + } + for (id, name) in &dashboards { + println!("# {name} [{id}]"); + match get_components(id) { + Ok(cs) if cs.is_empty() => println!(" (no components)"), + Ok(cs) => { + for c in cs { + println!(" {:<9} {:<9} {}", c.status, c.kind, c.label); + } + } + Err(e) => println!(" ! {e}"), + } + } + 0 +} + +// =========================================================================== +// Interactive TUI +// =========================================================================== + +fn status_glyph(status: &str) -> &'static str { + match status { + "running" => "●", + "starting" => "◐", + "attached" | "connected" => "◉", + "idle" | "stopped" => "○", + _ => "·", + } +} + +struct App { + dashboards: Vec<(String, String)>, + dash_idx: usize, + components: Vec, + input: String, + log: Vec, + quit: bool, + /// When set, we're in "terminal mode" streaming a component's live PTY. + term: Option, +} + +impl App { + fn new() -> Self { + let mut a = App { + dashboards: Vec::new(), + dash_idx: 0, + components: Vec::new(), + input: String::new(), + log: vec![ + "orcabot TUI — `help` for commands, Enter to run, Esc to quit.".to_string(), + ], + quit: false, + term: None, + }; + a.reload_dashboards(); + a.reload_components(); + a + } + + fn logln(&mut self, s: impl Into) { + self.log.push(s.into()); + if self.log.len() > 500 { + self.log.drain(0..self.log.len() - 500); + } + } + + fn current_dash_id(&self) -> Option { + self.dashboards.get(self.dash_idx).map(|(id, _)| id.clone()) + } + + fn reload_dashboards(&mut self) { + match list_dashboards() { + Ok(d) => { + self.dashboards = d; + if self.dash_idx >= self.dashboards.len() { + self.dash_idx = 0; + } + } + Err(e) => self.logln(format!("! dashboards: {e}")), + } + } + + fn reload_components(&mut self) { + match self.current_dash_id() { + Some(id) => match get_components(&id) { + Ok(c) => self.components = c, + Err(e) => self.logln(format!("! components: {e}")), + }, + None => self.components.clear(), + } + } + + fn run(&mut self, term: &mut DefaultTerminal) -> std::io::Result<()> { + while !self.quit { + // Keep the live PTY sized to the screen (minus the 1-line status bar). + if self.term.is_some() { + if let Ok(sz) = term.size() { + if let Some(t) = self.term.as_mut() { + t.resize(sz.height.saturating_sub(1), sz.width); + } + } + } + term.draw(|f| self.ui(f))?; + // Poll fast in terminal mode for responsive output. + let poll = if self.term.is_some() { 30 } else { 200 }; + if event::poll(Duration::from_millis(poll))? { + if let Event::Key(k) = event::read()? { + if k.kind == KeyEventKind::Press { + if self.term.is_some() { + self.on_key_terminal(k); + } else { + self.on_key_list(k); + } + } + } + } + } + Ok(()) + } + + /// Key handling while a live terminal is focused: forward to the PTY, except + /// Ctrl-] which detaches and returns to the component list. + fn on_key_terminal(&mut self, k: ratatui::crossterm::event::KeyEvent) { + if k.code == KeyCode::Char(']') && k.modifiers.contains(KeyModifiers::CONTROL) { + self.term = None; // Drop closes the WS thread. + self.logln("detached from terminal"); + self.reload_components(); + return; + } + if let Some(bytes) = key_to_bytes(k.code, k.modifiers) { + if let Some(t) = self.term.as_ref() { + t.send_input(bytes); + } + } + } + + /// Key handling in the component-list view: type + run commands. + fn on_key_list(&mut self, k: ratatui::crossterm::event::KeyEvent) { + match k.code { + KeyCode::Esc => self.quit = true, + KeyCode::Char('c') if k.modifiers.contains(KeyModifiers::CONTROL) => self.quit = true, + KeyCode::Enter => { + let line = std::mem::take(&mut self.input); + self.run_command(line.trim()); + } + KeyCode::Backspace => { + self.input.pop(); + } + KeyCode::Char(c) => self.input.push(c), + _ => {} + } + } + + /// Open a component (by 1-based index) as a live terminal pane. + fn open_component(&mut self, idx1: usize) { + let Some(dash) = self.current_dash_id() else { + self.logln("! no dashboard selected"); + return; + }; + let Some(c) = self.components.get(idx1.wrapping_sub(1)) else { + self.logln(format!("! no component #{idx1}")); + return; + }; + if c.kind != "terminal" { + self.logln(format!("! #{idx1} is a {} — only terminals can be opened", c.kind)); + return; + } + let item_id = c.id.clone(); + let (sid, pty) = match session_for_item(&dash, &item_id) { + Ok(v) => v, + Err(e) => { + self.logln(format!("! {e}")); + return; + } + }; + match spawn_pty_session(&item_id, &sid, &pty, 24, 80) { + Ok(s) => { + self.logln(format!("opened terminal #{idx1} (Ctrl-] to detach)")); + self.term = Some(s); + } + Err(e) => self.logln(format!("! open: {e}")), + } + } + + fn run_command(&mut self, line: &str) { + if line.is_empty() { + return; + } + self.logln(format!("> {line}")); + let parts: Vec<&str> = line.split_whitespace().collect(); + match parts.as_slice() { + ["help"] | ["?"] => { + for l in [ + "commands:", + " refresh|r reload dashboards + components", + " use switch to dashboard number ", + " dash list dashboards", + " dash new create a dashboard", + " new terminal start a terminal (claude|gemini|codex|shell)", + " open attach to terminal #n live (Ctrl-] to detach)", + " new note add a note component", + " connect get OAuth URL (gmail|drive|calendar|github|twitter)", + " attach attach a connected integration to terminal ", + " detach detach an integration from terminal ", + " rm delete a component by id", + " exec run a shell command in the sandbox VM", + " status service health", + " quit|q | Esc exit the TUI", + ] { + self.logln(l); + } + } + ["quit"] | ["q"] | ["exit"] => self.quit = true, + ["refresh"] | ["r"] => { + self.reload_dashboards(); + self.reload_components(); + self.logln("refreshed"); + } + ["dash"] => { + let lines: Vec = self + .dashboards + .iter() + .enumerate() + .map(|(i, (id, n))| format!(" {}{}. {} [{}]", if i == self.dash_idx { "*" } else { " " }, i + 1, n, id)) + .collect(); + if lines.is_empty() { + self.logln(" (no dashboards)"); + } else { + for l in lines { + self.logln(l); + } + } + } + ["dash", "new", rest @ ..] => { + let name = rest.join(" "); + let name = if name.is_empty() { "Untitled".to_string() } else { name }; + match cp_call("POST", "/dashboards", Some(serde_json::json!({ "name": name }))) { + Ok(_) => { + self.logln(format!("created dashboard '{name}'")); + self.reload_dashboards(); + self.reload_components(); + } + Err(e) => self.logln(format!("! {e}")), + } + } + ["new", "terminal", agent_rest @ ..] => { + let agent = agent_rest.first().copied().unwrap_or("claude"); + let Some(dash) = self.current_dash_id() else { + self.logln("! no dashboard selected (use `dash new ` first)"); + return; + }; + self.logln(format!("starting {agent} terminal…")); + match create_terminal(&dash, agent) { + Ok(id) => { + self.logln(format!("created terminal {}", &id[..id.len().min(8)])); + self.reload_components(); + } + Err(e) => self.logln(format!("! {e}")), + } + } + ["new", "note", note_rest @ ..] => { + let Some(dash) = self.current_dash_id() else { + self.logln("! no dashboard selected"); + return; + }; + match create_note(&dash, ¬e_rest.join(" ")) { + Ok(_) => { + self.logln("created note"); + self.reload_components(); + } + Err(e) => self.logln(format!("! {e}")), + } + } + ["connect", provider] => match connect_url(provider) { + Ok(url) => { + self.logln(format!("open to authorize {provider}:")); + self.logln(format!(" {url}")); + } + Err(e) => self.logln(format!("! {e}")), + }, + ["attach", terminal, provider] => { + let Some(dash) = self.current_dash_id() else { + self.logln("! no dashboard selected"); + return; + }; + match attach_integration(&dash, terminal, provider) { + Ok(()) => { + self.logln(format!("attached {provider} to {terminal}")); + self.reload_components(); + } + Err(e) => self.logln(format!("! {e}")), + } + } + ["detach", terminal, provider] => { + let Some(dash) = self.current_dash_id() else { + self.logln("! no dashboard selected"); + return; + }; + match detach_integration(&dash, terminal, provider) { + Ok(()) => { + self.logln(format!("detached {provider} from {terminal}")); + self.reload_components(); + } + Err(e) => self.logln(format!("! {e}")), + } + } + ["use", n] => match n.parse::() { + Ok(i) if i >= 1 && i <= self.dashboards.len() => { + self.dash_idx = i - 1; + self.reload_components(); + self.logln(format!("using dashboard {i}")); + } + _ => self.logln("! use : invalid index"), + }, + ["open", n] => match n.parse::() { + Ok(i) => self.open_component(i), + _ => self.logln("! open : component number from the list"), + }, + ["rm", id] => { + let Some(dash) = self.current_dash_id() else { + self.logln("! no dashboard selected"); + return; + }; + match cp_call("DELETE", &format!("/dashboards/{}/items/{}", dash, id), None) { + Ok(_) => { + self.logln(format!("deleted {id}")); + self.reload_components(); + } + Err(e) => self.logln(format!("! {e}")), + } + } + ["status"] => { + let cp = controlplane_healthy(); + let sb = sandbox_health().is_some(); + self.logln(format!( + "control plane: {} sandbox: {}", + if cp { "ready" } else { "down" }, + if sb { "ready" } else { "down" } + )); + } + ["exec", rest @ ..] if !rest.is_empty() => { + let out = run_in_vm(&rest.join(" ")); + for l in out.lines() { + self.logln(l.to_string()); + } + } + _ => self.logln(format!("! unknown command: {line} (try `help`)")), + } + } + + fn ui(&self, f: &mut Frame) { + if self.term.is_some() { + self.ui_terminal(f); + return; + } + let chunks = Layout::vertical([ + Constraint::Length(1), + Constraint::Min(3), + Constraint::Length(8), + Constraint::Length(3), + ]) + .split(f.area()); + + let dash_name = self + .dashboards + .get(self.dash_idx) + .map(|(_, n)| n.clone()) + .unwrap_or_else(|| "(no dashboard)".to_string()); + let header = Paragraph::new(format!( + " orcabot — {} [{} components] (Esc to quit, `help` for commands)", + dash_name, + self.components.len() + )) + .style(Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD)); + f.render_widget(header, chunks[0]); + + let items: Vec = if self.components.is_empty() { + vec![ListItem::new(" (no components — `dash new `, or create them in the app)")] + } else { + self.components + .iter() + .map(|c| { + ListItem::new(format!( + " {} {:<9} {:<9} {} ({})", + status_glyph(&c.status), + c.status, + c.kind, + c.label, + &c.id[..c.id.len().min(8)] + )) + }) + .collect() + }; + f.render_widget(List::new(items).block(Block::bordered().title("Components")), chunks[1]); + + let log_h = chunks[2].height.saturating_sub(2) as usize; + let start = self.log.len().saturating_sub(log_h.max(1)); + let log_lines: Vec = self.log[start..].iter().map(|l| Line::from(l.clone())).collect(); + f.render_widget( + Paragraph::new(log_lines).block(Block::bordered().title("Log")), + chunks[2], + ); + + f.render_widget( + Paragraph::new(format!("> {}", self.input)).block(Block::bordered().title("Command")), + chunks[3], + ); + } + + /// Render the focused live terminal: a status bar + the vt100 screen grid. + fn ui_terminal(&self, f: &mut Frame) { + let chunks = Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).split(f.area()); + let Some(term) = self.term.as_ref() else { return }; + + let bar = Paragraph::new(format!( + " terminal pty {} — {} (Ctrl-] to detach)", + term.pty_short, + term.parser_status() + )) + .style(Style::default().fg(Color::Black).bg(Color::Green).add_modifier(Modifier::BOLD)); + f.render_widget(bar, chunks[0]); + + let area = chunks[1]; + let Ok(parser) = term.parser.lock() else { return }; + let screen = parser.screen(); + let (rows, cols) = screen.size(); + let mut lines: Vec = Vec::with_capacity(rows as usize); + for r in 0..rows.min(area.height) { + let mut spans: Vec = Vec::with_capacity(cols as usize); + for c in 0..cols.min(area.width) { + match screen.cell(r, c) { + Some(cell) => { + let mut content = cell.contents(); + if content.is_empty() { + content = " ".to_string(); + } + let mut style = Style::default() + .fg(conv_color(cell.fgcolor())) + .bg(conv_color(cell.bgcolor())); + if cell.bold() { + style = style.add_modifier(Modifier::BOLD); + } + if cell.inverse() { + style = style.add_modifier(Modifier::REVERSED); + } + if cell.underline() { + style = style.add_modifier(Modifier::UNDERLINED); + } + spans.push(Span::styled(content, style)); + } + None => spans.push(Span::raw(" ")), + } + } + lines.push(Line::from(spans)); + } + f.render_widget(Paragraph::new(lines), area); + + // Mirror the PTY cursor (if visible) into the pane. + if !screen.hide_cursor() { + let (cr, cc) = screen.cursor_position(); + if cr < area.height && cc < area.width { + f.set_cursor_position((area.x + cc, area.y + cr)); + } + } + } +} + +/// Run a shell command in the sandbox VM, returning a human-readable result. +fn run_in_vm(command: &str) -> String { + let token = match read_debug_token() { + Some(t) => t, + None => return "! cannot read debug-exec token (was the stack started via `orcabot up`?)".into(), + }; + let body = serde_json::json!({ "cmd": command, "timeout_ms": 60000 }); + match agent(Duration::from_secs(65)) + .post(&format!("http://127.0.0.1:{}/debug/exec", SANDBOX_PORT)) + .set("X-Debug-Exec-Token", &token) + .set("Content-Type", "application/json") + .send_json(body) + { + Ok(r) => { + let j: serde_json::Value = r.into_json().unwrap_or_default(); + let mut s = String::new(); + if let Some(o) = j.get("stdout").and_then(|v| v.as_str()) { + s.push_str(o); + } + if let Some(e) = j.get("stderr").and_then(|v| v.as_str()) { + if !e.is_empty() { + s.push_str(e); + } + } + if s.trim().is_empty() { + s = format!("(exit {})", j.get("exit_code").and_then(|v| v.as_i64()).unwrap_or(0)); + } + s + } + Err(ureq::Error::Status(code, r)) => { + format!("! HTTP {code}: {}", r.into_string().unwrap_or_default().trim()) + } + Err(e) => format!("! exec failed: {e}"), + } +} + +fn cmd_tui() -> i32 { + if !controlplane_healthy() { + eprintln!("orcabot: stack not running — start it with `orcabot up` first."); + return 1; + } + let mut app = App::new(); + let mut terminal = ratatui::init(); + let res = app.run(&mut terminal); + ratatui::restore(); + if let Err(e) = res { + eprintln!("orcabot tui error: {e}"); + return 1; + } + 0 +} diff --git a/desktop/app/src-tauri/src/main.rs b/desktop/app/src-tauri/src/main.rs index 66d1fcc8..b521bba1 100644 --- a/desktop/app/src-tauri/src/main.rs +++ b/desktop/app/src-tauri/src/main.rs @@ -765,6 +765,26 @@ fn main() { } app.manage(Arc::clone(&services)); + + // Headless mode (used by the `orcabot` CLI): run all services in the + // background with no GUI. The window is created hidden (tauri.conf.json + // visible=false); in GUI mode we show it, in headless we leave it hidden and + // drop the macOS dock icon so this behaves like a daemon. + let headless = std::env::var("ORCABOT_DESKTOP_HEADLESS") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false); + if headless { + eprintln!("[main] HEADLESS: services running in background, no GUI window"); + #[cfg(target_os = "macos")] + { + let _ = app.set_activation_policy(tauri::ActivationPolicy::Accessory); + } + } else { + for (_, w) in app.webview_windows() { + let _ = w.show(); + let _ = w.set_focus(); + } + } Ok(()) }) .build(tauri::generate_context!()) diff --git a/desktop/app/src-tauri/tauri.conf.json b/desktop/app/src-tauri/tauri.conf.json index 72cd7732..1a373987 100644 --- a/desktop/app/src-tauri/tauri.conf.json +++ b/desktop/app/src-tauri/tauri.conf.json @@ -14,7 +14,8 @@ { "title": "Orcabot Desktop", "width": 1200, - "height": 800 + "height": 800, + "visible": false } ] },