commit 584da2d8256f43e6052bcf72006158b65e852c52 Author: 44r0n7 <44r0n7@users.noreply.git.44r0n.cc> Date: Tue Apr 14 09:02:32 2026 -0400 chore: scaffold tvctl foundation Set up the Rust crate, baseline module layout, and project docs so the repository matches the design bundle and builds cleanly as a starting point. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4ccbd93 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/target/ +/cache/ +/tvctl-agent-base.zip +.DS_Store diff --git a/AGENT.md b/AGENT.md new file mode 100644 index 0000000..98eba6a --- /dev/null +++ b/AGENT.md @@ -0,0 +1,209 @@ +# AGENT.md +# Instructions for AI Agents Working on tvctl +# Read this file completely before writing any code. + +--- + +## Step 1: Orient Yourself + +Before doing anything else, read these files in this order: + +1. `PROJECT_MAP.md` — architecture, decisions, data shapes, command tree, API surface +2. `ROADMAP.md` — current milestone, what's done, what's next +3. `README.md` — user-facing documentation (understand what you're building toward) + +Do not skip any of these. Do not begin coding until you have read all three. + +--- + +## Step 2: Understand What You Must Not Change + +The following decisions are final and were made intentionally during design. +Do not re-open, re-litigate, or silently deviate from them: + +- **Resource-verb CLI pattern** — `tvctl device list`, not `tvctl list-devices` +- **Unix socket for CLI↔daemon** — not TCP, not pipes +- **HTTP API for tool builders** — versioned at `/v1/`, loopback-only default +- **Adapter trait** — the exact interface defined in PROJECT_MAP.md +- **TOML config** — not YAML, not JSON +- **kebab-case key names** — `volume-up`, not `VolumeUp`, not `volume_up` +- **JSON response envelope** — `{ "ok": true, "data": {...} }` always +- **Organic app cache** — no pre-populated database, grows from live TV data +- **User-level systemd service** — not system-level, not root + +If you believe one of these decisions is wrong, document your concern in a +comment or note and ask for clarification. Do not silently work around them. + +--- + +## Step 3: Understand the Codebase Before Touching It + +Before modifying any file: + +1. Read the file you are about to change completely +2. Read any files it imports or depends on +3. Understand the data flow through the component you are changing + +Do not make changes based on filenames or directory structure alone. + +--- + +## Code Standards + +### Rust conventions +- Use `thiserror` for error types +- Use `tokio` for all async runtime +- Use `axum` for HTTP server +- Use `clap` (derive API) for CLI +- Use `serde` + `serde_json` for all serialization +- Use `uuid` crate for UUIDs +- Use `chrono` for timestamps +- Prefer `anyhow` for application-level error propagation +- All public types must have doc comments + +### Error handling +- Never use `.unwrap()` in non-test code unless you can prove it cannot fail +- Every error returned to the user (CLI or API) must include a `hint` field +- CLI errors must suggest the next action — not just report what went wrong +- API error `code` values are stable contracts — do not change existing codes + +### CLI output +- Human-readable output by default +- `--json` flag must work on every command +- Errors go to stderr, data goes to stdout +- Do not mix human text and JSON in the same output stream + +### API +- All endpoints return the standard envelope — no exceptions +- `error.code` values are snake_case strings +- `error.hint` is optional but strongly encouraged +- Never return platform-specific field names at the API surface level + +### Help text +Every command must have: +1. A one-line description +2. A short paragraph of context +3. A usage line +4. All subcommands/args listed with descriptions +5. At least two concrete examples +6. A notes section for technical details (if applicable) + +--- + +## File Responsibilities + +| File/Directory | Responsibility | Notes | +|----------------|----------------|-------| +| `src/main.rs` | Binary entry point, daemon vs CLI dispatch | Keep thin | +| `src/cli/` | All clap definitions and CLI handlers | No business logic here | +| `src/daemon/` | Daemon lifecycle, routing, services | Core of the application | +| `src/daemon/registry.rs` | Device registry, persistence | Owns devices.json | +| `src/daemon/discovery.rs` | SSDP discovery, polling | Platform-agnostic | +| `src/daemon/cache.rs` | App cache, persistence | Per-platform json files | +| `src/daemon/state.rs` | In-memory state cache | Never persisted | +| `src/api/` | axum HTTP server, route definitions | Thin layer over core | +| `src/adapters/mod.rs` | TvAdapter trait, TvKey, shared types | The contract | +| `src/adapters/roku/` | Roku ECP implementation | Only place Roku logic lives | + +The CLI and API layers must contain no business logic. They translate +user input into core calls and translate core results into output. +All logic lives in the daemon and adapter layers. + +--- + +## App Resolution Flow + +When a user runs `tvctl app launch netflix` or `POST /v1/devices/{id}/apps/launch`: + +``` +1. Check per-device installed app list in memory +2. If found → launch directly +3. If not found → check platform cache (roku.apps.json) +4. If found in cache → attempt launch (app might be installed) +5. If launch fails → report "not installed", suggest `tvctl app list` +6. If not in cache at all → fetch live app list from TV +7. Populate platform cache with all returned apps +8. Persist to cache file +9. Retry launch +``` + +Name matching is case-insensitive. Users can also pass raw platform IDs with `--id`. + +--- + +## Discovery Flow + +``` +1. tvctld starts +2. If discovery.auto_discover = true → run SSDP scan +3. SSDP returns device IP addresses +4. For each address → instantiate appropriate adapter → call adapter.discover() +5. Adapter returns DeviceInfo +6. Check if device UUID already exists in registry +7. If new → add to registry, use device-reported name as default friendly name +8. If existing → update last_seen timestamp +9. Persist registry to devices.json +``` + +Manual add bypasses SSDP — takes IP and platform directly. + +--- + +## Updating PROJECT_MAP.md + +You MUST update PROJECT_MAP.md when you: + +- Add a new source file +- Change the directory structure +- Implement a new CLI command or API endpoint +- Change a data shape +- Make a significant architectural decision +- Complete a roadmap milestone + +Update the "Last updated" date at the top of PROJECT_MAP.md with every change. + +Do not let PROJECT_MAP.md drift from the actual codebase. + +--- + +## Updating ROADMAP.md + +When you complete a task: + +1. Move it from "In Progress" or its milestone section to "Completed" +2. Add the completion date +3. Update the "Current Focus" section if the milestone changed + +When you start a task: + +1. Move it to "In Progress" +2. Note what you're working on + +--- + +## What To Do If You Are Unsure + +If you are unsure about: + +- **A design decision** → check PROJECT_MAP.md first. If not covered, ask. +- **What to build next** → check ROADMAP.md current milestone. +- **How a component should behave** → check README.md for user-facing behavior. +- **Whether to add a feature** → default to no. Prefer smaller scope. + +When in doubt, do less. A smaller correct implementation is better than +a larger incorrect one. This project has a clear identity — do not add +things that don't serve it. + +--- + +## Definition of Done + +A feature is done when: + +- [ ] It works correctly +- [ ] `--json` output works if it's a CLI command +- [ ] Errors include helpful `hint` text +- [ ] Help text is complete (description, usage, examples, notes) +- [ ] No `.unwrap()` in non-test paths +- [ ] PROJECT_MAP.md is updated if structure changed +- [ ] ROADMAP.md is updated to reflect completion diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..156a263 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,5 @@ +# AGENTS.md + +See [AGENT.md](AGENT.md) for the canonical agent instructions for this repository. +This compatibility file exists so agent tooling that expects `AGENTS.md` can +discover the same project guidance without duplicating the instructions. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..ad69b18 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1370 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.185" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[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 = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.51.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tvctl" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "chrono", + "clap", + "serde", + "serde_json", + "thiserror", + "tokio", + "toml", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +dependencies = [ + "getrandom", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..ec6b7f7 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "tvctl" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1.0" +axum = "0.8" +chrono = { version = "0.4", features = ["serde"] } +clap = { version = "4.5", features = ["derive"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +thiserror = "2.0" +tokio = { version = "1.0", features = ["full"] } +toml = "0.8" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } +uuid = { version = "1.0", features = ["serde", "v4"] } diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md new file mode 100644 index 0000000..58629a8 --- /dev/null +++ b/PROJECT_MAP.md @@ -0,0 +1,354 @@ +# PROJECT_MAP.md +# tvctl — Living Project Map +# This file is the single source of truth for any agent working on this project. +# Read this first. Update this when you make structural changes. +# Last updated: 2026-04-14 + +--- + +## What This Project Is + +`tvctl` is a Rust CLI tool and daemon (`tvctld`) that provides a unified, brand-agnostic +control layer for smart TVs. It exposes a CLI and a local HTTP API so that technical users +and tool builders can control any supported TV without caring about the underlying platform. + +**One sentence:** A local-first daemon and CLI that lets technical users and tool builders +script and control smart TVs through a stable, brand-agnostic API. + +--- + +## Project Status + +**Phase:** Milestone 1 scaffolded. Foundation compiles; runtime logic not started. +**Platform v1:** Roku only (via ECP HTTP API) +**Language:** Rust +**Crate type:** Binary (single binary distribution target) + +--- + +## Directory Structure + +``` +tvctl/ +├── .gitignore ← Repository hygiene and runtime artifact exclusions +├── AGENTS.md ← Compatibility shim pointing to AGENT.md +├── README.md ← User-facing documentation (complete) +├── PROJECT_MAP.md ← This file. Agent context. Always read first. +├── AGENT.md ← Instructions for AI agents working on this project +├── ROADMAP.md ← Feature roadmap and milestone tracking +├── Cargo.toml ← Crate manifest and dependency definitions +├── Cargo.lock ← Locked dependency graph +├── docs/ +│ ├── DESIGN.md ← Full design decisions and rationale +│ ├── API.md ← HTTP API specification (detailed) +│ └── ADAPTER.md ← Adapter trait spec and implementation guide +├── src/ +│ ├── main.rs ← Binary entry point and runtime bootstrap +│ ├── cli/ ← CLI layer (clap-based scaffold) +│ │ └── mod.rs +│ ├── daemon/ ← tvctld daemon core scaffolding +│ │ ├── mod.rs +│ │ ├── registry.rs ← Device registry +│ │ ├── discovery.rs ← SSDP discovery service +│ │ ├── cache.rs ← App cache manager +│ │ └── state.rs ← State cache +│ ├── api/ ← HTTP API server scaffold (axum) +│ │ └── mod.rs +│ └── adapters/ ← Platform adapters and shared types +│ ├── mod.rs ← Adapter trait definition and core data shapes +│ └── roku/ ← Roku ECP adapter scaffold +│ └── mod.rs +└── cache/ ← Runtime cache (gitignored) +``` + +--- + +## Core Design Decisions + +These are final. Do not re-open without explicit instruction. + +| Decision | Choice | Reason | +|----------|--------|--------| +| Language | Rust | Developer familiarity, single binary, async ecosystem | +| CLI framework | clap | Best-in-class for Rust, shell completions, polished help | +| HTTP framework | axum | Mature, tokio-native, clean routing | +| CLI↔daemon transport | Unix socket | Secure, no port conflicts, user-only access | +| Tool builder transport | HTTP API | Stable, versioned, JSON | +| Config format | TOML | Rust ecosystem standard, human-readable | +| CLI pattern | resource-verb | Consistent, mirrors REST, scales well | +| API versioning | /v1/ from day one | Stability signal to tool builders | +| App cache strategy | Organic growth from live TV data | Accurate, small, zero maintenance | +| State cache | In-memory only, cleared on restart | Honest — never pretend to have stale state | +| Device registry | Persisted to devices.json | Survives restarts | +| Default HTTP bind | 127.0.0.1 | Secure by default | +| Key naming | kebab-case normalized | Unix convention, adapter handles translation | +| Systemd | User-level service, not system | Runs as user, not root | + +--- + +## Architecture + +``` +CLI (tvctl) + └── Unix Socket + └── tvctld daemon + ├── Core Router + ├── Device Registry (devices.json) + ├── App Cache (cache/{platform}.apps.json) + ├── State Cache (in-memory) + ├── Discovery Service (SSDP) + ├── Adapter Registry + │ └── Roku Adapter (ECP/HTTP) + └── HTTP API Server (:7272) + └── /v1/... (tool builders) +``` + +--- + +## Adapter Trait + +The central abstraction. All platform implementations must satisfy this trait. +Defined in `src/adapters/mod.rs`. + +```rust +pub trait TvAdapter: Send + Sync { + async fn discover(&self) -> Result>; + async fn state(&self, device: &Device) -> Result; + async fn launch(&self, device: &Device, app: &str) -> Result<()>; + async fn stop_app(&self, device: &Device) -> Result<()>; + async fn key(&self, device: &Device, key: TvKey) -> Result<()>; + async fn sequence(&self, device: &Device, keys: Vec) -> Result<()>; + async fn list_apps(&self, device: &Device) -> Result>; + async fn dev_install(&self, device: &Device, zip: &[u8]) -> Result<()> { + Err(TvError::NotSupported("dev_install")) + } + async fn dev_reload(&self, device: &Device) -> Result<()> { + Err(TvError::NotSupported("dev_reload")) + } + async fn dev_logs(&self, device: &Device) -> Result> { + Err(TvError::NotSupported("dev_logs")) + } +} +``` + +Dev methods have default implementations that return `NotSupported`. +Platforms that support them override. Nothing above the adapter layer +needs to know which platforms support which features. + +--- + +## Data Shapes + +### Device +```rust +pub struct Device { + pub id: Uuid, + pub name: String, // user-assigned friendly name + pub original_name: String, // name as reported by device at discovery + pub platform: String, // "roku" | "googletv" | "firetv" + pub address: IpAddr, + pub port: u16, + pub is_default: bool, + pub discovered_at: DateTime, + pub last_seen: DateTime, +} +``` + +### DeviceState +```rust +pub struct DeviceState { + pub device_id: Uuid, + pub power: PowerState, // On | Off | Unknown + pub active_app: Option, + pub volume: Option, + pub timestamp: DateTime, +} +``` + +### AppInfo +```rust +pub struct AppInfo { + pub id: String, // tvctl normalized id + pub name: String, + pub version: Option, + pub platform_id: String, // raw platform value +} +``` + +### TvKey (normalized key enum) +```rust +pub enum TvKey { + Home, Back, Up, Down, Left, Right, Select, + Play, Pause, PlayPause, Stop, Rewind, FastForward, Replay, Skip, + ChannelUp, ChannelDown, + VolumeUp, VolumeDown, Mute, + Power, PowerOn, PowerOff, + InputHdmi1, InputHdmi2, InputHdmi3, InputHdmi4, InputAv, InputTuner, + Search, Info, Options, + Literal(String), // for text input +} +``` + +--- + +## CLI Command Tree + +``` +tvctl +├── (bare) full help output +├── daemon +│ ├── start +│ ├── stop +│ ├── restart +│ ├── status +│ ├── install generate + enable systemd user service +│ └── uninstall +├── device +│ ├── list +│ ├── discover +│ ├── add --address --platform +│ ├── select +│ ├── info +│ └── remove +├── app +│ ├── list +│ ├── launch +│ ├── stop +│ └── refresh +├── remote +│ ├── key +│ └── sequence [key...] +├── state +├── dev +│ ├── install +│ ├── reload +│ └── logs +└── config + ├── list + ├── get + ├── set + ├── reset + └── reload + +Global flags: --device --json --help --version +``` + +--- + +## HTTP API Surface + +Base: `http://127.0.0.1:7272/v1` + +``` +GET /v1/devices +POST /v1/devices/discover +GET /v1/devices/{id} +DELETE /v1/devices/{id} +GET /v1/devices/{id}/state +GET /v1/devices/{id}/apps +POST /v1/devices/{id}/apps/launch body: {"app": "netflix"} +POST /v1/devices/{id}/apps/stop +POST /v1/devices/{id}/apps/refresh +POST /v1/devices/{id}/remote/key body: {"key": "home"} +POST /v1/devices/{id}/remote/sequence body: {"keys": ["home", "down"]} +POST /v1/devices/{id}/dev/install body: multipart zip +POST /v1/devices/{id}/dev/reload +GET /v1/devices/{id}/dev/logs +GET /v1/daemon/status +GET /v1/config +PATCH /v1/config +POST /v1/config/reload +``` + +Response envelope (always): +```json +{ "ok": true, "data": { ... } } +{ "ok": false, "error": { "code": "...", "message": "...", "hint": "..." } } +``` + +--- + +## Storage Layout + +``` +~/.config/tvctl/config.toml ← user config (TOML) +~/.local/share/tvctl/devices.json ← device registry (daemon managed) +~/.local/share/tvctl/cache/ + roku.apps.json ← organic app cache per platform + googletv.apps.json + firetv.apps.json +/run/user/{uid}/tvctl.sock ← unix socket (runtime) +``` + +--- + +## Config Schema + +```toml +[daemon] +socket = "/run/user/1000/tvctl.sock" +http_enabled = true +http_port = 7272 +http_host = "127.0.0.1" +log_level = "info" + +[discovery] +auto_discover = true +interval_secs = 300 +timeout_secs = 5 + +[devices] +default = "" + +[dev] +enabled = true +``` + +--- + +## Platform Support + +| Platform | Status | Protocol | +|----------|--------|----------| +| Roku | v1 target | ECP (HTTP on port 8060) | +| Google TV | post-MVP | ADB | +| Fire TV | post-MVP | ADB | +| Samsung | deprioritized | Tizen (unstable app IDs) | + +--- + +## Known Risks + +| Risk | Severity | Mitigation | +|------|----------|------------| +| Roku ECP changes without notice | High/Low-likelihood | Adapter isolation | +| SSDP blocked on some networks | Medium | Manual device add fallback | +| App name resolution ambiguity | Low | Raw --id flag fallback | +| State staleness | Low | Honest timestamps on all state | +| Unix socket permissions | Medium | 0600, user-level systemd service | +| Platform creep | Medium | Roku-first discipline in v1 | + +--- + +## What Has NOT Been Started + +- Roku ECP transport and device discovery logic +- Daemon runtime, socket transport, and persistence logic +- HTTP route handlers and request validation +- Real CLI command handling beyond skeleton parsing +- Any tests +- CI/CD configuration +- Release/packaging + +--- + +## Glossary + +| Term | Meaning | +|------|---------| +| ECP | External Control Protocol — Roku's HTTP control API on port 8060 | +| SSDP | Simple Service Discovery Protocol — used for device discovery on LAN | +| Adapter | A platform-specific implementation of the TvAdapter trait | +| Registry | The persisted list of known devices (devices.json) | +| Platform cache | Per-platform app list cache (grows from live TV data) | +| Device cache | Per-device installed app list (subset of platform cache) | diff --git a/README.md b/README.md new file mode 100644 index 0000000..a509ea8 --- /dev/null +++ b/README.md @@ -0,0 +1,469 @@ +# tvctl + +> A local-first daemon and CLI that provides a unified, brand-agnostic control layer for smart TVs — +> giving technical users and tool builders a stable, scriptable API without caring what's connected. + +--- + +## What is tvctl? + +`tvctl` is a self-hosted control plane for smart TVs. It runs as a background daemon (`tvctld`) on your +Linux machine and exposes two interfaces: + +- A **CLI** (`tvctl`) for direct control, scripting, and exploration +- A **local HTTP API** (`/v1/...`) for tool builders and automation + +The goal is simple: make your TV fully scriptable and explorable from the command line, without cloud +dependencies, without proprietary apps, and without caring what brand of TV you own. + +If you've ever wanted to `curl` your TV, write a script that launches an app, automate input switching, +or build your own dashboard — `tvctl` is the foundation that makes that possible. + +--- + +## Who is this for? + +**Homelab operators** who want their TV under the same level of control as the rest of their infrastructure. + +**Tool builders** who want to build TV-adjacent tooling (stream deck plugins, Home Assistant integrations, +automation scripts) without reinventing device discovery and platform APIs from scratch. + +**Curious technical users** who want to explore what their TV can actually do. + +`tvctl` is not for average TV users. If you're comfortable with a terminal and you've ever felt limited +by your TV's remote, you're the target audience. + +--- + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────┐ +│ tvctld │ +│ │ +│ ┌─────────────┐ ┌──────────────────────────┐ │ +│ │ Unix Socket │ │ HTTP API (:7272) │ │ +│ │ (CLI only) │ │ /v1/... (tool builders) │ │ +│ └──────┬──────┘ └────────────┬─────────────┘ │ +│ │ │ │ +│ └──────────┬─────────────┘ │ +│ │ │ +│ ┌─────────▼──────────┐ │ +│ │ Core Router │ │ +│ └─────────┬──────────┘ │ +│ │ │ +│ ┌──────────────┼──────────────┐ │ +│ │ │ │ │ +│ ┌──▼───┐ ┌─────▼────┐ ┌────▼────┐ │ +│ │Device│ │ App │ │Discovery│ │ +│ │Registry │ Cache │ │Service │ │ +│ └──────┘ └──────────┘ └─────────┘ │ +│ │ │ +│ ┌─────────▼──────────┐ │ +│ │ Adapter Registry │ │ +│ └─────────┬──────────┘ │ +│ │ │ +│ ┌──────────┴──────────┐ │ +│ │ │ │ +│ ┌────▼────┐ ┌─────▼──────┐ │ +│ │ Roku │ │ Google TV │ ← future │ +│ │ Adapter │ │ Adapter │ │ +│ │ (ECP) │ │ (ADB) │ │ +│ └─────────┘ └────────────┘ │ +└─────────────────────────────────────────────────────┘ +``` + +### Components + +**`tvctld` — The Daemon** +The heart of the system. Runs as a persistent background service (systemd user unit). Owns device +discovery, the device registry, app cache, and state cache. All CLI commands and API requests go +through the daemon. + +**Unix Socket** +The CLI communicates with the daemon exclusively via a Unix socket (`~/.run/user/1000/tvctl.sock`). +Fast, secure, no port conflicts. The socket is only accessible to the running user. + +**HTTP API** +The daemon optionally exposes a local HTTP API on `127.0.0.1:7272`. This is the interface for tool +builders — Home Assistant integrations, stream deck plugins, scripts, dashboards. Loopback-only by +default. All responses are JSON. + +**Adapter Layer** +Each TV platform is implemented as an adapter behind a common Rust trait. The adapter handles all +platform-specific protocol details. Above the adapter layer, nothing knows or cares whether the TV +is a Roku, Google TV, or anything else. Adding a new platform means implementing the adapter trait — +nothing else changes. + +**Device Registry** +Persisted to `~/.local/share/tvctl/devices.json`. Survives daemon restarts. Devices are discovered +once and remembered forever (or until removed). Each device has a UUID, a friendly name, and a +platform tag that maps it to an adapter. + +**App Cache** +Per-platform, persisted to `~/.local/share/tvctl/cache/{platform}.apps.json`. Grows organically +from live TV data — no pre-populated database. The first time you launch an app by name, tvctld +fetches the full app list from the TV, caches it, and never fetches it again unless you ask it to. +App IDs are stable on all supported platforms. + +**State Cache** +In-memory only. Last known state per device. Includes power status, active app, and volume. Always +includes a timestamp so callers know how fresh the data is. Cleared on daemon restart. + +--- + +## CLI Reference + +`tvctl` uses a consistent **resource-verb** pattern throughout: + +``` +tvctl [args] [flags] +``` + +Global flags available on every command: + +``` +--device Target a specific device (overrides default) +--json Output as JSON for scripting +--help Show help for any command +--version Show tvctl version +``` + +### daemon + +Manage the tvctld background service. + +``` +tvctl daemon start Start the daemon +tvctl daemon stop Stop the daemon +tvctl daemon restart Restart the daemon +tvctl daemon status Show daemon status +tvctl daemon install Generate and enable systemd user service +tvctl daemon uninstall Remove systemd user service +``` + +### device + +Discover and manage connected TVs. + +``` +tvctl device list List all known devices +tvctl device discover Scan network for TVs (SSDP) +tvctl device add --address Manually add a device +tvctl device select Set default device +tvctl device info Show device details +tvctl device remove Remove from registry +``` + +### app + +Manage and launch TV applications. + +``` +tvctl app list List installed apps on current device +tvctl app launch Launch an app by name or platform ID +tvctl app stop Stop the current app +tvctl app refresh Refresh app cache from TV +``` + +### remote + +Send input to the TV. + +``` +tvctl remote key Send a single keypress +tvctl remote sequence [key...] Send a sequence of keypresses +``` + +**Available keys:** + +| Category | Keys | +|------------|---------------------------------------------------| +| Navigation | `home` `back` `up` `down` `left` `right` `select` | +| Playback | `play` `pause` `play-pause` `stop` `rewind` `fast-forward` `replay` `skip` | +| Channel | `channel-up` `channel-down` | +| Volume | `volume-up` `volume-down` `mute` | +| Power | `power` `power-on` `power-off` | +| Input | `input-hdmi1` `input-hdmi2` `input-hdmi3` `input-hdmi4` `input-av` `input-tuner` | +| Other | `search` `info` `options` | + +### state + +Query current device state. + +``` +tvctl state Show state of default device +tvctl state --device Show state of a specific device +``` + +### dev + +Developer tools. Requires `dev.enabled = true` in config. + +``` +tvctl dev install Sideload a zip to the TV +tvctl dev reload Reload the currently sideloaded app +tvctl dev logs Fetch developer logs +``` + +### config + +View and manage tvctl configuration. + +``` +tvctl config list Show all config values +tvctl config get Get a specific value +tvctl config set Set a config value +tvctl config reset Reset to defaults +tvctl config reload Hot-reload config into running daemon +``` + +--- + +## HTTP API Reference + +The API mirrors the CLI exactly. If you know the CLI, you know the API. + +Base URL: `http://127.0.0.1:7272/v1` + +All requests and responses are JSON. All responses use a consistent envelope: + +```json +{ "ok": true, "data": { ... } } +``` + +Errors: + +```json +{ + "ok": false, + "error": { + "code": "device_not_found", + "message": "No device with id 'bedroom' exists.", + "hint": "Run tvctl device list to see known devices." + } +} +``` + +`code` is stable and machine-readable. `hint` is human-readable and may change between versions. +Tool builders should match on `code`, never on `hint` or `message`. + +### Endpoints + +``` +GET /v1/devices List all devices +POST /v1/devices/discover Trigger discovery +GET /v1/devices/{id} Device info +DELETE /v1/devices/{id} Remove device +GET /v1/devices/{id}/state Device state +GET /v1/devices/{id}/apps List installed apps +POST /v1/devices/{id}/apps/launch Launch app +POST /v1/devices/{id}/apps/stop Stop current app +POST /v1/devices/{id}/apps/refresh Refresh app cache +POST /v1/devices/{id}/remote/key Send keypress +POST /v1/devices/{id}/remote/sequence Send key sequence +POST /v1/devices/{id}/dev/install Sideload zip +POST /v1/devices/{id}/dev/reload Reload sideloaded app +GET /v1/devices/{id}/dev/logs Fetch logs +GET /v1/daemon/status Daemon health +GET /v1/config Get full config +PATCH /v1/config Update config values +POST /v1/config/reload Hot reload config +``` + +Devices can be addressed by UUID or friendly name: + +``` +GET /v1/devices/a3f2c1d4-.../state +GET /v1/devices/living-room/state ← same result +``` + +### Example Requests + +```bash +# List devices +curl http://127.0.0.1:7272/v1/devices + +# Launch Netflix on living room TV +curl -X POST http://127.0.0.1:7272/v1/devices/living-room/apps/launch \ + -H "Content-Type: application/json" \ + -d '{"app": "netflix"}' + +# Send keypress +curl -X POST http://127.0.0.1:7272/v1/devices/living-room/remote/key \ + -H "Content-Type: application/json" \ + -d '{"key": "home"}' + +# Get state +curl http://127.0.0.1:7272/v1/devices/living-room/state +``` + +--- + +## Data Shapes + +### Device + +```json +{ + "id": "a3f2c1d4-9b3e-4f1a-8c2d-1e5f7a9b3c4d", + "name": "Living Room", + "original_name": "Living Room Roku", + "platform": "roku", + "address": "192.168.1.42", + "port": 8060, + "is_default": true, + "discovered_at": "2026-04-14T10:22:00Z", + "last_seen": "2026-04-14T11:05:00Z" +} +``` + +### State + +```json +{ + "device_id": "a3f2c1d4-...", + "power": "on", + "active_app": { + "id": "12", + "name": "Netflix" + }, + "volume": { + "level": 42, + "muted": false + }, + "timestamp": "2026-04-14T11:05:00Z" +} +``` + +### App + +```json +{ + "id": "12", + "name": "Netflix", + "version": "4.1.218", + "platform_id": "12" +} +``` + +--- + +## Configuration + +Located at `~/.config/tvctl/config.toml` + +```toml +[daemon] +socket = "/run/user/1000/tvctl.sock" +http_enabled = true +http_port = 7272 +http_host = "127.0.0.1" +log_level = "info" + +[discovery] +auto_discover = true +interval_secs = 300 +timeout_secs = 5 + +[devices] +default = "living-room" + +[dev] +enabled = true +``` + +### Config Keys + +| Key | Default | Description | +|-----|---------|-------------| +| `daemon.socket` | `/run/user/{uid}/tvctl.sock` | Unix socket path | +| `daemon.http_enabled` | `true` | Expose HTTP API | +| `daemon.http_port` | `7272` | HTTP API port | +| `daemon.http_host` | `127.0.0.1` | Bind address (loopback by default) | +| `daemon.log_level` | `info` | debug / info / warn / error | +| `discovery.auto_discover` | `true` | Discover devices on daemon start | +| `discovery.interval_secs` | `300` | Re-scan interval (0 = disabled) | +| `discovery.timeout_secs` | `5` | Per-device timeout | +| `devices.default` | `""` | Default device name or UUID | +| `dev.enabled` | `true` | Enable dev tooling commands | + +--- + +## Storage Layout + +``` +~/.config/tvctl/ +└── config.toml + +~/.local/share/tvctl/ +├── devices.json ← device registry (daemon managed) +└── cache/ + ├── roku.apps.json ← organic app cache, grows from live data + ├── googletv.apps.json + └── firetv.apps.json +``` + +--- + +## Platform Support + +| Platform | Status | Protocol | Notes | +|----------|--------|----------|-------| +| Roku | v1 | ECP (HTTP) | Full support | +| Google TV | planned | ADB | Post-MVP | +| Fire TV | planned | ADB | Post-MVP | +| Samsung | investigated | Tizen | Deprioritized — unstable app IDs | + +--- + +## Getting Started + +```bash +# Install (once binary releases exist) +# cargo install tvctl + +# Start the daemon +tvctl daemon start + +# Or install as a systemd user service +tvctl daemon install + +# Discover TVs on your network +tvctl device discover + +# Set your default device +tvctl device select "Living Room" + +# Launch an app +tvctl app launch netflix + +# Send a keypress +tvctl remote key home + +# Query state +tvctl state + +# Scripting example +tvctl state --json | jq '.data.active_app.name' +``` + +--- + +## For Tool Builders + +`tvctl` is designed to be a stable foundation for other tools. The HTTP API is versioned from day one +(`/v1/...`) and the data shapes are intentional contracts. + +If you're building a Home Assistant integration, a stream deck plugin, or any other TV-adjacent tool: + +- Use the HTTP API, not the CLI, for programmatic access +- Address devices by UUID for stability, not friendly name +- Match on `error.code` for error handling, never on `error.message` +- The API is loopback-only by default — document this for your users + +--- + +## License + +MIT diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..c3f1e87 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,172 @@ +# ROADMAP.md +# tvctl — Feature Roadmap and Milestone Tracker +# Agents: update this file as work is completed. See AGENT.md for instructions. +# Last updated: 2026-04-14 + +--- + +## Current Focus + +**Milestone 2 — Roku Adapter** +Foundation scaffold is complete. Begin platform implementation work. + +--- + +## In Progress + +_Nothing in progress yet._ + +--- + +## Milestone 1 — Project Foundation +_Goal: A compiling Rust project with correct structure and no logic yet._ + +_Completed 2026-04-14. See Completed below._ + +--- + +## Milestone 2 — Roku Adapter +_Goal: Can communicate with a real Roku TV over ECP._ + +- [ ] Implement Roku ECP adapter in `src/adapters/roku/` +- [ ] `discover()` — SSDP scan returning Roku devices +- [ ] `list_apps()` — fetch installed channel list via ECP +- [ ] `launch()` — launch app by ECP channel ID +- [ ] `stop_app()` — exit current app +- [ ] `key()` — send ECP keypress +- [ ] `sequence()` — send multiple keypresses +- [ ] `state()` — query power state and active app +- [ ] `dev_install()` — zip upload via ECP dev mode +- [ ] `dev_reload()` — reload sideloaded app +- [ ] `dev_logs()` — fetch dev logs +- [ ] Key translation table (TvKey → Roku ECP key string) +- [ ] Manual integration test against real Roku device + +--- + +## Milestone 3 — Daemon Core +_Goal: tvctld runs, manages devices, and handles the Unix socket._ + +- [ ] Daemon entry point and lifecycle (`src/daemon/mod.rs`) +- [ ] Unix socket listener +- [ ] Device registry (`src/daemon/registry.rs`) + - Load from `devices.json` on start + - Persist on change + - CRUD operations +- [ ] Discovery service (`src/daemon/discovery.rs`) + - SSDP scan + - Auto-discover on startup (if configured) + - Interval-based re-scan + - Manual add by IP +- [ ] App cache manager (`src/daemon/cache.rs`) + - Per-platform JSON files + - Organic growth strategy + - `app refresh` invalidation +- [ ] State cache (`src/daemon/state.rs`) + - In-memory only + - Per-device last-known state + - Timestamp on every entry +- [ ] Adapter registry (map platform string → adapter instance) +- [ ] Config loading from TOML + +--- + +## Milestone 4 — CLI +_Goal: All tvctl commands work against a running daemon._ + +- [ ] CLI entry point and dispatch (`src/cli/mod.rs`) +- [ ] Unix socket client (send commands, receive responses) +- [ ] `tvctl daemon` commands + - `start` `stop` `restart` `status` + - `install` (generate systemd user unit) + - `uninstall` +- [ ] `tvctl device` commands + - `list` `discover` `add` `select` `info` `remove` +- [ ] `tvctl app` commands + - `list` `launch` `stop` `refresh` +- [ ] `tvctl remote` commands + - `key` `sequence` +- [ ] `tvctl state` +- [ ] `tvctl dev` commands + - `install` `reload` `logs` +- [ ] `tvctl config` commands + - `list` `get` `set` `reset` `reload` +- [ ] Global flags: `--device` `--json` `--help` `--version` +- [ ] Full help text on every command (see AGENT.md definition of done) +- [ ] Full help output on bare `tvctl` +- [ ] Friendly error messages with hints on every failure path +- [ ] `--json` output verified on every command + +--- + +## Milestone 5 — HTTP API +_Goal: Full /v1/ API running on 127.0.0.1:7272._ + +- [ ] axum server setup in `src/api/mod.rs` +- [ ] All routes implemented (see PROJECT_MAP.md API surface) +- [ ] Standard response envelope on all routes +- [ ] Error responses with `code` + `message` + `hint` +- [ ] Device addressable by UUID or friendly name on all routes +- [ ] `PATCH /v1/config` with partial update support +- [ ] `POST /v1/config/reload` triggers live config reload in daemon +- [ ] Integration test: curl all endpoints against running daemon + +--- + +## Milestone 6 — Polish and Release Prep +_Goal: Ready for real use._ + +- [ ] Shell completions (bash, zsh, fish) via clap +- [ ] `tvctl daemon install` generates correct systemd unit file +- [ ] First-run experience: helpful output when no devices discovered yet +- [ ] Daemon startup message with socket path and HTTP port +- [ ] Log output via `tracing` (respects `log_level` config) +- [ ] README accuracy pass (verify all examples work) +- [ ] `cargo clippy` clean +- [ ] `cargo test` passing +- [ ] Cross-compile test (x86_64 + aarch64) +- [ ] GitHub Actions CI (build + clippy + test) +- [ ] First binary release + +--- + +## Post-MVP (Do Not Implement in v1) + +These are captured here so they are not forgotten, but they are explicitly +out of scope until Milestone 6 is complete and stable. + +- [ ] Google TV / Android TV adapter (ADB) +- [ ] Fire TV adapter (ADB variant) +- [ ] WebSocket live state updates +- [ ] Event watching / triggers +- [ ] Device groups (send command to multiple TVs) +- [ ] Automation rules engine +- [ ] Home Assistant integration +- [ ] Web UI (consumes HTTP API, no business logic) +- [ ] `tvctl dev logs` streaming (currently returns last N lines only) +- [ ] macOS support (launchd instead of systemd) + +--- + +## Completed + +- [x] 2026-04-14 — Initialize Cargo workspace (`cargo init`) +- [x] 2026-04-14 — Add baseline dependencies to `Cargo.toml` +- [x] 2026-04-14 — Create module skeleton and placeholder docs layout +- [x] 2026-04-14 — Define the adapter contract and core shared data types +- [x] 2026-04-14 — Compile the project cleanly with `cargo build` + +--- + +## Decision Log + +Significant decisions made during development should be logged here +so future agents understand why things are the way they are. + +| Date | Decision | Reason | +|------|----------|--------| +| 2026-04-14 | Full design completed before any code written | Intentional — design-first approach | +| 2026-04-14 | Samsung deprioritized | Unstable app IDs per region/model make universal support unreliable | +| 2026-04-14 | No pre-populated app database | Organic cache from live TV data is more accurate and zero-maintenance | +| 2026-04-14 | Unix socket for CLI, HTTP for tool builders | Clean security boundary, loopback-only by default | +| 2026-04-14 | User-level systemd service | No root required, correct ownership model | diff --git a/docs/ADAPTER.md b/docs/ADAPTER.md new file mode 100644 index 0000000..9736d21 --- /dev/null +++ b/docs/ADAPTER.md @@ -0,0 +1,33 @@ +# ADAPTER.md +# tvctl Adapter Contract + +The adapter layer isolates platform-specific TV control protocols from the +rest of `tvctl`. Everything above this layer speaks the normalized tvctl +data model; everything below it speaks the platform's native protocol. + +## Required Trait + +The canonical trait lives in `src/adapters/mod.rs`: + +```rust +pub trait TvAdapter: Send + Sync { + async fn discover(&self) -> Result>; + async fn state(&self, device: &Device) -> Result; + async fn launch(&self, device: &Device, app: &str) -> Result<()>; + async fn stop_app(&self, device: &Device) -> Result<()>; + async fn key(&self, device: &Device, key: TvKey) -> Result<()>; + async fn sequence(&self, device: &Device, keys: Vec) -> Result<()>; + async fn list_apps(&self, device: &Device) -> Result>; + async fn dev_install(&self, device: &Device, zip: &[u8]) -> Result<()>; + async fn dev_reload(&self, device: &Device) -> Result<()>; + async fn dev_logs(&self, device: &Device) -> Result>; +} +``` + +## Rules + +- The adapter surface is the boundary between normalized and platform data. +- Adapters translate normalized `TvKey` values into platform-specific input. +- Platform support belongs in a dedicated subdirectory under `src/adapters/`. +- Dev-mode methods may return `NotSupported` when a platform lacks that feature. +- The CLI, daemon, and HTTP API must not contain platform-specific logic. diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..5a0f94a --- /dev/null +++ b/docs/API.md @@ -0,0 +1,60 @@ +# API.md +# tvctl HTTP API Specification + +`tvctl` exposes a loopback-only HTTP API for tool builders at `/v1/...`. +This file captures the v1 surface promised by the design bundle so the +repository has a dedicated API spec from day one. + +## Base URL + +`http://127.0.0.1:7272/v1` + +## Response Envelope + +Successful responses: + +```json +{ "ok": true, "data": { "...": "..." } } +``` + +Error responses: + +```json +{ + "ok": false, + "error": { + "code": "device_not_found", + "message": "No device with id 'living-room' exists.", + "hint": "Run tvctl device list to see known devices." + } +} +``` + +## Planned Routes + +- `GET /devices` +- `POST /devices/discover` +- `GET /devices/{id}` +- `DELETE /devices/{id}` +- `GET /devices/{id}/state` +- `GET /devices/{id}/apps` +- `POST /devices/{id}/apps/launch` +- `POST /devices/{id}/apps/stop` +- `POST /devices/{id}/apps/refresh` +- `POST /devices/{id}/remote/key` +- `POST /devices/{id}/remote/sequence` +- `POST /devices/{id}/dev/install` +- `POST /devices/{id}/dev/reload` +- `GET /devices/{id}/dev/logs` +- `GET /daemon/status` +- `GET /config` +- `PATCH /config` +- `POST /config/reload` + +## API Rules + +- All routes return the standard JSON envelope. +- `error.code` values are stable machine contracts. +- `error.hint` is optional but recommended for user-actionable failures. +- Devices may be addressed by UUID or friendly name. +- Platform-specific field names must not leak past the adapter layer. diff --git a/docs/DESIGN.md b/docs/DESIGN.md new file mode 100644 index 0000000..336c59e --- /dev/null +++ b/docs/DESIGN.md @@ -0,0 +1,181 @@ +# DESIGN.md +# tvctl — Design Decisions and Rationale +# This document explains the why behind decisions in PROJECT_MAP.md. +# Read this if you want to understand the reasoning, not just the choices. + +--- + +## Vision + +tvctl exists because the Roku ECP API (and similar TV control APIs) are fully capable +but completely inaccessible to technical users without writing custom code. There is no +good CLI tool, no stable local API, and no abstraction layer that works across TV brands. + +The goal is to be that abstraction layer — the foundation that other tools are built on, +and a useful tool in its own right. + +--- + +## Why a Daemon? + +A stateless CLI that talks directly to TVs on every invocation would be simpler to build +but has fundamental limitations: + +- Device discovery (SSDP) takes time — you don't want it on every command +- The app cache needs to live somewhere persistent and shared +- An HTTP API for tool builders needs a persistent process +- State caching requires something running continuously + +The daemon owns all of this. The CLI is a thin client. This is the correct architecture +for a tool with these requirements. + +--- + +## Why Unix Socket for CLI, HTTP for API? + +Two different audiences, two different needs: + +**CLI users** are local, trusted, and interactive. A Unix socket is: +- Faster than HTTP for local IPC +- Inherently secure (filesystem permissions, user-only access) +- No port to conflict with other software +- The right tool for local process communication + +**Tool builders** need HTTP because: +- Every language has an HTTP client +- REST is a universal interface +- It works from scripts, Home Assistant, stream decks, anything +- Versioning (`/v1/`) is natural in HTTP + +Keeping these separate means the HTTP API port is optional — you can disable it entirely +and still use the full CLI. Security-conscious users appreciate this. + +--- + +## Why Resource-Verb CLI Pattern? + +``` +tvctl device list ← resource-verb +tvctl list-devices ← verb-resource (rejected) +``` + +Resource-verb was chosen because: + +1. It mirrors REST naturally — `GET /devices` → `tvctl device list` +2. It scales — as commands grow, the namespace stays organized +3. Tab completion is more useful — type `tvctl device ` to see all device commands +4. It matches tools users already know (`git remote add`, `kubectl pod get`) + +The 1:1 mapping between CLI and API is intentional and valuable. Learn one, know both. + +--- + +## Why Organic App Cache? + +The temptation is to ship a pre-populated database of app IDs. This was explicitly rejected: + +- It requires ongoing maintenance as apps are added/removed +- It would contain apps the user doesn't have installed (bloat, confusion) +- App IDs are stable — once discovered, they never need to be re-fetched +- The first-fetch delay is tiny and happens once per app, ever + +The organic approach means the cache is always perfectly accurate for this user's TV. +It grows automatically through normal use. It requires zero maintenance. + +--- + +## Why Loopback-Only HTTP by Default? + +The HTTP API exposes control over your TV. Binding to `0.0.0.0` by default would mean +anyone on your network could control your TV without authentication. + +Loopback-only (`127.0.0.1`) is the secure default. Users who want network exposure can +change `http_host` in config — but they make that choice consciously. + +There is no authentication system in v1. The security model is network isolation. +This is appropriate for a local tool used by one person. + +--- + +## Why User-Level Systemd Service? + +`tvctl daemon install` installs a systemd **user** service, not a system service. This means: + +- No `sudo` required at any point +- The daemon runs as the installing user +- The Unix socket is owned by that user +- Uninstalling is clean and doesn't require root + +System-level services are for system daemons. tvctld is a user tool. + +--- + +## Why TOML for Config? + +- It's the de facto standard in the Rust ecosystem +- Human-readable and editable without special knowledge +- Better than YAML (no indentation pitfalls, no Norway problem) +- Better than JSON for config (supports comments, more readable) +- `serde` + `toml` crate support is first-class in Rust + +--- + +## Why kebab-case for Key Names? + +``` +volume-up ← chosen +VolumeUp ← Roku's internal name (rejected at surface) +volume_up ← snake_case (rejected) +``` + +- kebab-case is the Unix CLI convention for multi-word values +- It's consistent with how flags are named (`--json`, `--device`) +- The adapter layer handles translation to platform-specific names +- Users never need to know Roku uses `VolumeUp` internally + +--- + +## The Adapter Abstraction + +This is the most important architectural decision in the project. + +The adapter trait is a wall between the platform world and the application world. +Everything above the adapter layer speaks tvctld's language. Everything below speaks +the platform's language. + +Consequences: +- Adding Google TV support means writing one new file (`src/adapters/googletv/`) +- The CLI, API, registry, and cache don't change at all +- Platform bugs are isolated to their adapter +- Testing adapters is straightforward — mock the trait + +The dev methods (`dev_install`, `dev_reload`, `dev_logs`) have default implementations +that return `NotSupported`. This means the trait can be implemented by platforms that +don't support developer mode without those platforms needing to handle it explicitly. + +--- + +## State Cache Design + +State is cached in memory only and cleared on daemon restart. This is intentional: + +- TVs don't push state — you have to poll +- Cached state can be stale by definition +- Pretending to have live state you don't have is dishonest and confusing +- Every state response includes a `timestamp` so callers know how fresh it is + +In a future version, WebSocket streaming or polling could make state more live. +For v1, honest polling with clear timestamps is the right approach. + +--- + +## App Resolution Priority + +When resolving an app name to a platform ID: + +1. Per-device installed app list (in memory, freshest) +2. Platform cache (on disk, still accurate) +3. Live fetch from TV (fallback, always works) + +This order minimizes network calls while always being able to resolve. +The user can also bypass resolution entirely with a raw `--id` flag. diff --git a/src/adapters/mod.rs b/src/adapters/mod.rs new file mode 100644 index 0000000..a689169 --- /dev/null +++ b/src/adapters/mod.rs @@ -0,0 +1,203 @@ +use std::net::IpAddr; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use uuid::Uuid; + +pub mod roku; + +/// The shared result type for adapter operations. +pub type Result = std::result::Result; + +/// A platform adapter capable of controlling one class of TVs. +pub trait TvAdapter: Send + Sync { + /// Discover candidate devices for this platform. + async fn discover(&self) -> Result>; + + /// Fetch the current state of a known device. + async fn state(&self, device: &Device) -> Result; + + /// Launch an application identified by a normalized or platform app ID. + async fn launch(&self, device: &Device, app: &str) -> Result<()>; + + /// Stop the currently running application on the device. + async fn stop_app(&self, device: &Device) -> Result<()>; + + /// Send a single normalized keypress to the device. + async fn key(&self, device: &Device, key: TvKey) -> Result<()>; + + /// Send a sequence of normalized keypresses to the device. + async fn sequence(&self, device: &Device, keys: Vec) -> Result<()>; + + /// Return the apps currently installed on the device. + async fn list_apps(&self, device: &Device) -> Result>; + + /// Install a development package on the device, if supported. + async fn dev_install(&self, _device: &Device, _zip: &[u8]) -> Result<()> { + Err(TvError::NotSupported("dev_install")) + } + + /// Reload the active development package, if supported. + async fn dev_reload(&self, _device: &Device) -> Result<()> { + Err(TvError::NotSupported("dev_reload")) + } + + /// Fetch development logs from the device, if supported. + async fn dev_logs(&self, _device: &Device) -> Result> { + Err(TvError::NotSupported("dev_logs")) + } +} + +/// Device data returned by discovery before registry assignment. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct DeviceInfo { + /// The device-reported display name. + pub name: String, + /// The normalized platform identifier. + pub platform: String, + /// The IP address used to reach the device. + pub address: IpAddr, + /// The port used by the platform protocol. + pub port: u16, +} + +/// A device tracked by the tvctl registry. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Device { + /// The stable tvctl UUID for the device. + pub id: Uuid, + /// The user-assigned friendly name. + pub name: String, + /// The original name reported by the device during discovery. + pub original_name: String, + /// The normalized platform identifier. + pub platform: String, + /// The IP address used to reach the device. + pub address: IpAddr, + /// The port used by the platform protocol. + pub port: u16, + /// Whether this is the default target device. + pub is_default: bool, + /// When the device was first discovered by tvctl. + pub discovered_at: DateTime, + /// When the device was most recently seen online. + pub last_seen: DateTime, +} + +/// A normalized power state for a device. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum PowerState { + /// The device reports it is on. + On, + /// The device reports it is off. + Off, + /// The platform cannot currently determine power state. + Unknown, +} + +/// Volume information returned from a device state query. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct VolumeInfo { + /// The device volume level when available. + pub level: u8, + /// Whether the device is muted. + pub muted: bool, +} + +/// The last known state for a device. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct DeviceState { + /// The UUID of the device this state belongs to. + pub device_id: Uuid, + /// The normalized power state. + pub power: PowerState, + /// The currently active application, if known. + pub active_app: Option, + /// The currently observed volume state, if available. + pub volume: Option, + /// When this snapshot was collected. + pub timestamp: DateTime, +} + +/// Metadata about an application installed on a device. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AppInfo { + /// The tvctl-normalized app identifier. + pub id: String, + /// The human-readable app name. + pub name: String, + /// Optional app version returned by the platform. + pub version: Option, + /// The raw platform-specific app identifier. + pub platform_id: String, +} + +/// A normalized key identifier accepted by the CLI and API. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum TvKey { + Home, + Back, + Up, + Down, + Left, + Right, + Select, + Play, + Pause, + PlayPause, + Stop, + Rewind, + FastForward, + Replay, + Skip, + ChannelUp, + ChannelDown, + VolumeUp, + VolumeDown, + Mute, + Power, + PowerOn, + PowerOff, + InputHdmi1, + InputHdmi2, + InputHdmi3, + InputHdmi4, + InputAv, + InputTuner, + Search, + Info, + Options, + Literal(String), +} + +/// A structured error produced by adapter implementations. +#[derive(Debug, Error)] +pub enum TvError { + /// The adapter does not support the requested feature. + #[error("platform does not support {0}")] + NotSupported(&'static str), + /// The requested device could not be found. + #[error("device '{0}' was not found")] + DeviceNotFound(String), + /// The platform rejected or could not perform an operation. + #[error("device '{0}' is currently unavailable")] + DeviceUnavailable(String), + /// The user supplied a key the platform cannot translate. + #[error("invalid key '{0}'")] + InvalidKey(String), + /// A transport-level request failed. + #[error("transport error: {0}")] + Transport(String), + /// Configuration loading or validation failed. + #[error("configuration error: {0}")] + Config(String), + /// A serialization boundary failed. + #[error("serialization error: {0}")] + Serialization(String), + /// An I/O operation failed. + #[error("i/o error: {0}")] + Io(#[from] std::io::Error), +} diff --git a/src/adapters/roku/mod.rs b/src/adapters/roku/mod.rs new file mode 100644 index 0000000..5d34815 --- /dev/null +++ b/src/adapters/roku/mod.rs @@ -0,0 +1,42 @@ +use super::{AppInfo, Device, DeviceInfo, DeviceState, Result, TvAdapter, TvError, TvKey}; + +/// The Roku ECP adapter placeholder for the foundation milestone. +#[derive(Debug, Clone, Default)] +pub struct RokuAdapter; + +impl RokuAdapter { + /// Create a new Roku adapter instance. + pub fn new() -> Self { + Self + } +} + +impl TvAdapter for RokuAdapter { + async fn discover(&self) -> Result> { + Err(TvError::NotSupported("discover")) + } + + async fn state(&self, _device: &Device) -> Result { + Err(TvError::NotSupported("state")) + } + + async fn launch(&self, _device: &Device, _app: &str) -> Result<()> { + Err(TvError::NotSupported("launch")) + } + + async fn stop_app(&self, _device: &Device) -> Result<()> { + Err(TvError::NotSupported("stop_app")) + } + + async fn key(&self, _device: &Device, _key: TvKey) -> Result<()> { + Err(TvError::NotSupported("key")) + } + + async fn sequence(&self, _device: &Device, _keys: Vec) -> Result<()> { + Err(TvError::NotSupported("sequence")) + } + + async fn list_apps(&self, _device: &Device) -> Result> { + Err(TvError::NotSupported("list_apps")) + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..db91001 --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,37 @@ +use axum::Router; +use serde::Serialize; + +/// Create the placeholder HTTP router for the tvctl API. +pub fn router() -> Router { + Router::new() +} + +/// The standard success envelope for API responses. +#[derive(Debug, Clone, Serialize)] +pub struct SuccessEnvelope { + /// Indicates a successful operation. + pub ok: bool, + /// The payload returned by the request. + pub data: T, +} + +/// The standard error envelope for API responses. +#[derive(Debug, Clone, Serialize)] +pub struct ErrorEnvelope { + /// Indicates the request failed. + pub ok: bool, + /// The structured error payload. + pub error: ApiError, +} + +/// A machine-readable API error returned to clients. +#[derive(Debug, Clone, Serialize)] +pub struct ApiError { + /// Stable, snake_case error identifier. + pub code: String, + /// Human-readable summary of the failure. + pub message: String, + /// Suggested next action for the caller. + #[serde(skip_serializing_if = "Option::is_none")] + pub hint: Option, +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs new file mode 100644 index 0000000..348779d --- /dev/null +++ b/src/cli/mod.rs @@ -0,0 +1,47 @@ +use clap::{Parser, Subcommand}; + +/// The tvctl command-line interface. +#[derive(Debug, Parser)] +#[command( + name = "tvctl", + version, + about = "A local-first daemon and CLI for controlling smart TVs." +)] +pub struct Cli { + /// Target a specific device by friendly name or UUID. + #[arg(long, global = true)] + pub device: Option, + + /// Emit JSON output suitable for scripting. + #[arg(long, global = true)] + pub json: bool, + + /// The resource-oriented command to execute. + #[command(subcommand)] + pub command: Option, +} + +/// The top-level resource namespaces exposed by tvctl. +#[derive(Debug, Subcommand)] +pub enum Command { + /// Manage the background daemon. + Daemon, + /// Discover and manage devices. + Device, + /// List, launch, and stop applications. + App, + /// Send remote control input. + Remote, + /// Query device state. + State, + /// Use developer-oriented TV features. + Dev, + /// Inspect and modify tvctl configuration. + Config, +} + +/// Parse the CLI and return successfully for the repository scaffold. +pub async fn run() -> anyhow::Result<()> { + let _ = Cli::parse(); + Ok(()) +} diff --git a/src/daemon/cache.rs b/src/daemon/cache.rs new file mode 100644 index 0000000..15c88cb --- /dev/null +++ b/src/daemon/cache.rs @@ -0,0 +1,10 @@ +use crate::adapters::AppInfo; + +/// A platform-level cache of app metadata discovered from live devices. +#[derive(Debug, Clone, Default)] +pub struct AppCache { + /// The normalized platform identifier for the cache file. + pub platform: String, + /// The apps currently known for that platform. + pub apps: Vec, +} diff --git a/src/daemon/discovery.rs b/src/daemon/discovery.rs new file mode 100644 index 0000000..4edbbb5 --- /dev/null +++ b/src/daemon/discovery.rs @@ -0,0 +1,3 @@ +/// Background discovery orchestration for supported TV platforms. +#[derive(Debug, Clone, Default)] +pub struct DiscoveryService; diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs new file mode 100644 index 0000000..b961b7d --- /dev/null +++ b/src/daemon/mod.rs @@ -0,0 +1,8 @@ +pub mod cache; +pub mod discovery; +pub mod registry; +pub mod state; + +/// The long-lived tvctld process. +#[derive(Debug, Default)] +pub struct Daemon; diff --git a/src/daemon/registry.rs b/src/daemon/registry.rs new file mode 100644 index 0000000..defc678 --- /dev/null +++ b/src/daemon/registry.rs @@ -0,0 +1,8 @@ +use crate::adapters::Device; + +/// The persisted collection of known devices. +#[derive(Debug, Clone, Default)] +pub struct DeviceRegistry { + /// All devices currently remembered by the daemon. + pub devices: Vec, +} diff --git a/src/daemon/state.rs b/src/daemon/state.rs new file mode 100644 index 0000000..68bb17e --- /dev/null +++ b/src/daemon/state.rs @@ -0,0 +1,12 @@ +use std::collections::HashMap; + +use uuid::Uuid; + +use crate::adapters::DeviceState; + +/// An in-memory cache of the last observed state for each device. +#[derive(Debug, Clone, Default)] +pub struct StateCache { + /// State entries keyed by device UUID. + pub entries: HashMap, +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..0ebd0c7 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,21 @@ +#![allow(dead_code)] + +mod adapters; +mod api; +mod cli; +mod daemon; + +/// Launch the tvctl binary entry point. +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::from_default_env() + .add_directive(tracing::Level::INFO.into()), + ) + .with_target(false) + .compact() + .init(); + + cli::run().await +}