diff --git a/Cargo.lock b/Cargo.lock index 087cad8..6065f75 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -223,6 +223,15 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_complete" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff7a1dccbdd8b078c2bdebff47e404615151534d5043da397ec50286816f9cb" +dependencies = [ + "clap", +] + [[package]] name = "clap_derive" version = "4.6.0" @@ -1686,6 +1695,7 @@ dependencies = [ "axum", "chrono", "clap", + "clap_complete", "libc", "md5", "reqwest", diff --git a/Cargo.toml b/Cargo.toml index 72ab1df..68d5216 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ anyhow = "1.0" axum = { version = "0.8", features = ["multipart"] } chrono = { version = "0.4", features = ["serde"] } clap = { version = "4.5", features = ["derive"] } +clap_complete = "4.5" libc = "0.2" md5 = "0.7" reqwest = { version = "0.12", default-features = false, features = ["charset", "http2", "json", "multipart", "rustls-tls"] } diff --git a/README.md b/README.md index 46b62e2..974c6b4 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,16 @@ tvctl config reset Reset to defaults tvctl config reload Hot-reload config into running daemon ``` +### completion + +Generate shell completions to stdout. + +```bash +tvctl completion bash > ~/.local/share/bash-completion/completions/tvctl +tvctl completion zsh > ~/.zfunc/_tvctl +tvctl completion fish > ~/.config/fish/completions/tvctl.fish +``` + --- ## HTTP API Reference @@ -462,6 +472,9 @@ tvctl state # Scripting example tvctl state --json | jq '.data.active_app.name' + +# Optional shell completions +tvctl completion zsh > ~/.zfunc/_tvctl ``` --- diff --git a/ROADMAP.md b/ROADMAP.md index b2972f4..406150e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,20 +1,20 @@ # 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 +# Last updated: 2026-04-15 --- ## Current Focus -**Milestone 5 — HTTP API** -HTTP route parity with the daemon is now in progress. Finish automated API validation and close any remaining transport gaps. +**Milestone 6 — Polish and Release Prep** +Close the low-risk polish items that make the CLI easier to install, discover, and use day to day. --- ## In Progress -- Milestone 5 is in progress; the `/v1` Axum server and core route surface are implemented, but automated HTTP validation is still missing +- Milestone 6 is in progress; shell completions and first-run CLI polish are landing first while heavier release work stays pending --- @@ -116,14 +116,14 @@ _Goal: Full /v1/ API running on 127.0.0.1:7272._ ## Milestone 6 — Polish and Release Prep _Goal: Ready for real use._ -- [ ] Shell completions (bash, zsh, fish) via clap +- [x] 2026-04-15 — 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 +- [x] 2026-04-15 — First-run experience: helpful output when no devices discovered yet +- [x] 2026-04-15 — 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 +- [x] 2026-04-15 — README accuracy pass (verify all examples work) +- [x] 2026-04-15 — `cargo clippy` clean +- [x] 2026-04-15 — `cargo test` passing - [ ] Cross-compile test (x86_64 + aarch64) - [ ] GitHub Actions CI (build + clippy + test) - [ ] First binary release @@ -164,6 +164,8 @@ out of scope until Milestone 6 is complete and stable. - [x] 2026-04-14 — Add daemon-backed app control, remote input, state queries, and dev workflows with live Roku CLI validation - [x] 2026-04-14 — Add config list/get/set/reset/reload plus systemd user-service install/uninstall commands - [x] 2026-04-14 — Finish Milestone 4 with help, JSON-mode, config socket-reload, and secret-redaction polish +- [x] 2026-04-15 — Add HTTP API route parity and integration coverage against an isolated running daemon +- [x] 2026-04-15 — Add shell completions and first-run CLI polish for Milestone 6 --- diff --git a/src/api/mod.rs b/src/api/mod.rs index bcdb3b0..a35b350 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -18,7 +18,8 @@ use tokio::{ use crate::{ adapters::{Device, TvKey, parse_normalized_tv_key}, daemon::{ - SharedDaemon, config::TvctlConfig, + SharedDaemon, + config::TvctlConfig, ipc::{ AppListResult, ConfigReloadResult, DaemonRequest, DaemonResponse, DaemonStatus, DevLogsResult, DiscoveryResult, StateResult, @@ -115,13 +116,7 @@ async fn delete_device(Path(id): Path, State(daemon): State, State(daemon): State) -> Response { - execute_json::( - daemon, - DaemonRequest::GetState { - device: Some(id), - }, - ) - .await + execute_json::(daemon, DaemonRequest::GetState { device: Some(id) }).await } async fn list_apps(Path(id): Path, State(daemon): State) -> Response { @@ -151,13 +146,7 @@ async fn launch_app( } async fn stop_app(Path(id): Path, State(daemon): State) -> Response { - execute_json_value( - daemon, - DaemonRequest::StopApp { - device: Some(id), - }, - ) - .await + execute_json_value(daemon, DaemonRequest::StopApp { device: Some(id) }).await } async fn refresh_apps( @@ -182,7 +171,7 @@ async fn send_key( ) -> Response { let key = match parse_key(&body.key) { Ok(key) => key, - Err(response) => return response, + Err(response) => return *response, }; execute_json_value( daemon, @@ -203,7 +192,7 @@ async fn send_sequence( for key in body.keys { match parse_key(&key) { Ok(key) => parsed.push(key), - Err(response) => return response, + Err(response) => return *response, } } execute_json_value( @@ -277,23 +266,11 @@ async fn dev_install( } async fn dev_reload(Path(id): Path, State(daemon): State) -> Response { - execute_json_value( - daemon, - DaemonRequest::DevReload { - device: Some(id), - }, - ) - .await + execute_json_value(daemon, DaemonRequest::DevReload { device: Some(id) }).await } async fn dev_logs(Path(id): Path, State(daemon): State) -> Response { - execute_json::( - daemon, - DaemonRequest::DevLogs { - device: Some(id), - }, - ) - .await + execute_json::(daemon, DaemonRequest::DevLogs { device: Some(id) }).await } async fn daemon_status(State(daemon): State) -> Response { @@ -342,7 +319,9 @@ async fn patch_config( StatusCode::BAD_REQUEST, "invalid_config_value", format!("Config value for '{key}' must be a string, number, boolean, or null."), - Some("Use flat key/value JSON such as {\"daemon.http_port\": 7272}.".to_string()), + Some( + "Use flat key/value JSON such as {\"daemon.http_port\": 7272}.".to_string(), + ), ); } }; @@ -365,8 +344,7 @@ async fn patch_config( ); } - let response = execute_json_value(daemon, DaemonRequest::ReloadConfig).await; - response + execute_json_value(daemon, DaemonRequest::ReloadConfig).await } async fn reload_config(State(daemon): State) -> Response { @@ -395,7 +373,12 @@ where T: Serialize + DeserializeOwned, { if let Some(error) = response.error { - return api_error(status_for_error(&error.code), error.code, error.message, error.hint); + return api_error( + status_for_error(&error.code), + error.code, + error.message, + error.hint, + ); } let data = response.data.unwrap_or(Value::Null); @@ -437,14 +420,17 @@ fn api_error( .into_response() } -fn parse_key(input: &str) -> Result { +fn parse_key(input: &str) -> Result> { parse_normalized_tv_key(input).map_err(|_| { - api_error( + Box::new(api_error( StatusCode::BAD_REQUEST, "invalid_key", format!("Unknown key '{input}'."), - Some("Use a normalized key like `home`, `down`, `volume-up`, or `literal:text`.".to_string()), - ) + Some( + "Use a normalized key like `home`, `down`, `volume-up`, or `literal:text`." + .to_string(), + ), + )) }) } @@ -486,8 +472,14 @@ async fn send_daemon_request( api_error( StatusCode::INTERNAL_SERVER_ERROR, "daemon_socket_unreachable", - format!("Unable to reach tvctld at {}: {error}", socket_path.display()), - Some("Check whether the daemon socket is writable and the daemon is running.".to_string()), + format!( + "Unable to reach tvctld at {}: {error}", + socket_path.display() + ), + Some( + "Check whether the daemon socket is writable and the daemon is running." + .to_string(), + ), ) })?; @@ -517,14 +509,17 @@ async fn send_daemon_request( })?; let mut response_bytes = Vec::new(); - stream.read_to_end(&mut response_bytes).await.map_err(|error| { - api_error( - StatusCode::INTERNAL_SERVER_ERROR, - "daemon_read_failed", - format!("Failed to read the daemon response: {error}"), - Some("Retry the request after restarting the daemon.".to_string()), - ) - })?; + stream + .read_to_end(&mut response_bytes) + .await + .map_err(|error| { + api_error( + StatusCode::INTERNAL_SERVER_ERROR, + "daemon_read_failed", + format!("Failed to read the daemon response: {error}"), + Some("Retry the request after restarting the daemon.".to_string()), + ) + })?; serde_json::from_slice::(&response_bytes).map_err(|error| { api_error( diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 0df22da..ad5ae4d 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,6 +1,7 @@ use std::{collections::BTreeMap, net::IpAddr, path::PathBuf, process::Stdio, time::Duration}; -use clap::{Args, CommandFactory, Parser, Subcommand}; +use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum}; +use clap_complete::{Generator, Shell, generate}; use serde::Serialize; use thiserror::Error; use tokio::{ @@ -97,11 +98,23 @@ pub enum Command { #[command(subcommand)] command: ConfigCommand, }, + /// Generate shell completion scripts. + Completion { + #[arg(value_enum)] + shell: CompletionShell, + }, /// Internal daemon entry point used by `tvctl daemon start`. #[command(hide = true, name = "__daemon_serve")] InternalDaemonServe, } +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum CompletionShell { + Bash, + Zsh, + Fish, +} + /// Manage the tvctld lifecycle. #[derive(Debug, Clone, Subcommand)] pub enum DaemonCommand { @@ -320,9 +333,29 @@ pub async fn run() -> Result<(), CliError> { Command::State => handle_state_command(&cli).await, Command::Dev { command } => handle_dev_command(&cli, command).await, Command::Config { command } => handle_config_command(&cli, command).await, + Command::Completion { shell } => handle_completion_command(shell), } } +fn handle_completion_command(shell: CompletionShell) -> Result<(), CliError> { + let mut command = Cli::command(); + match shell { + CompletionShell::Bash => print_completions(Shell::Bash, &mut command), + CompletionShell::Zsh => print_completions(Shell::Zsh, &mut command), + CompletionShell::Fish => print_completions(Shell::Fish, &mut command), + } + Ok(()) +} + +fn print_completions(generator: G, command: &mut clap::Command) { + generate( + generator, + command, + command.get_name().to_string(), + &mut std::io::stdout(), + ); +} + async fn handle_daemon_command(cli: &Cli, command: DaemonCommand) -> Result<(), CliError> { match command { DaemonCommand::Start => daemon_start(cli).await, @@ -626,10 +659,7 @@ async fn daemon_start(cli: &Cli) -> Result<(), CliError> { if let Some(status) = daemon_status_payload().await { if service_owns_daemon(&service, &status) { return render(cli, &status, || { - format!( - "tvctld user service is already running on {}", - status.socket - ) + render_daemon_started("tvctld user service is already running.", &status) }); } stop_ad_hoc_daemon(&status).await?; @@ -638,13 +668,13 @@ async fn daemon_start(cli: &Cli) -> Result<(), CliError> { run_systemctl(&["--user", "start", TVCTLD_SYSTEMD_UNIT]).await?; let status = wait_for_daemon_ready().await?; return render(cli, &status, || { - format!("Started tvctld user service on {}", status.socket) + render_daemon_started("Started tvctld user service.", &status) }); } if let Some(status) = daemon_status_payload().await { return render(cli, &status, || { - format!("tvctld is already running on {}", status.socket) + render_daemon_started("tvctld is already running.", &status) }); } @@ -670,7 +700,7 @@ async fn daemon_start(cli: &Cli) -> Result<(), CliError> { let status = wait_for_daemon_ready().await?; render(cli, &status, || { - format!("tvctld started on {}", status.socket) + render_daemon_started("tvctld started.", &status) }) } @@ -725,15 +755,15 @@ async fn daemon_stop(cli: &Cli) -> Result<(), CliError> { async fn daemon_restart(cli: &Cli) -> Result<(), CliError> { let service = systemd_service_status().await?; if service.installed { - if let Some(status) = daemon_status_payload().await { - if !service_owns_daemon(&service, &status) { - stop_ad_hoc_daemon(&status).await?; - } + if let Some(status) = daemon_status_payload().await + && !service_owns_daemon(&service, &status) + { + stop_ad_hoc_daemon(&status).await?; } run_systemctl(&["--user", "restart", TVCTLD_SYSTEMD_UNIT]).await?; let status = wait_for_daemon_ready().await?; return render(cli, &status, || { - format!("Restarted tvctld user service on {}", status.socket) + render_daemon_started("Restarted tvctld user service.", &status) }); } @@ -1166,7 +1196,11 @@ fn render_action_response(cli: &Cli, response: DaemonResponse) -> Result<(), Cli fn render_device_list(devices: &[Device]) -> String { if devices.is_empty() { - return "No devices are registered yet.".to_string(); + return [ + "No devices are registered yet.".to_string(), + "Run `tvctl device discover` to scan the network or `tvctl device add --platform roku --address ` to add one manually.".to_string(), + ] + .join("\n"); } devices @@ -1187,7 +1221,11 @@ fn render_device_list(devices: &[Device]) -> String { fn render_discovery_result(devices: &[Device]) -> String { if devices.is_empty() { - return "Discovery completed, but no devices were found.".to_string(); + return [ + "Discovery completed, but no devices were found.".to_string(), + "Make sure the TV is powered on and reachable, then retry or add it manually with `tvctl device add --platform roku --address `.".to_string(), + ] + .join("\n"); } format!( "Discovered {} device(s).\n{}", @@ -1300,6 +1338,21 @@ fn render_daemon_status( lines.join("\n") } +fn render_daemon_started(prefix: &str, status: &DaemonStatus) -> String { + let http = if status.http_enabled { + format!("http://{}:{}/v1", status.http_host, status.http_port) + } else { + "disabled".to_string() + }; + + [ + prefix.to_string(), + format!("Socket: {}", status.socket), + format!("HTTP: {http}"), + ] + .join("\n") +} + fn render_service_install(result: &ServiceInstallResult) -> String { if result.already_installed { let running = if result.running { "yes" } else { "no" }; diff --git a/src/daemon/config.rs b/src/daemon/config.rs index 18a5884..a886f38 100644 --- a/src/daemon/config.rs +++ b/src/daemon/config.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use tokio::fs; /// The complete daemon configuration loaded from TOML. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(default)] pub struct TvctlConfig { /// Runtime daemon settings. @@ -24,18 +24,6 @@ pub struct TvctlConfig { pub dev: DevConfig, } -impl Default for TvctlConfig { - fn default() -> Self { - Self { - daemon: DaemonConfig::default(), - discovery: DiscoveryConfig::default(), - devices: DeviceConfig::default(), - remote: RemoteConfig::default(), - dev: DevConfig::default(), - } - } -} - impl TvctlConfig { /// Load configuration from the default XDG path or return defaults when absent. pub async fn load() -> anyhow::Result { diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index f80423a..7de6841 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -183,7 +183,9 @@ async fn set_socket_permissions(path: &Path) -> anyhow::Result<()> { Ok(()) } -async fn start_http_server_if_enabled(daemon: SharedDaemon) -> anyhow::Result>> { +async fn start_http_server_if_enabled( + daemon: SharedDaemon, +) -> anyhow::Result>> { let (enabled, host, port) = { let guard = daemon.lock().await; ( @@ -469,17 +471,15 @@ pub(crate) async fn execute_request( Ok(device) => device, Err(response) => return (response, false), }; - if clear { - if let Err(error) = guard.app_cache.clear_platform(&device.platform).await { - return ( - DaemonResponse::error( - "app_cache_clear_failed", - format!("Failed to clear the cached app list: {error}"), - Some("Check permissions for ~/.local/share/tvctl/cache.".to_string()), - ), - false, - ); - } + if clear && let Err(error) = guard.app_cache.clear_platform(&device.platform).await { + return ( + DaemonResponse::error( + "app_cache_clear_failed", + format!("Failed to clear the cached app list: {error}"), + Some("Check permissions for ~/.local/share/tvctl/cache.".to_string()), + ), + false, + ); } match guard.adapters.list_apps(&device).await { Ok(apps) => match guard diff --git a/src/daemon/registry.rs b/src/daemon/registry.rs index 755bf95..bd3dfdd 100644 --- a/src/daemon/registry.rs +++ b/src/daemon/registry.rs @@ -179,19 +179,11 @@ impl Default for DeviceRegistry { } /// A registry of platform adapters available to the daemon. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] pub struct AdapterRegistry { roku: RokuAdapter, } -impl Default for AdapterRegistry { - fn default() -> Self { - Self { - roku: RokuAdapter::new(), - } - } -} - impl AdapterRegistry { /// Build the adapter registry from the loaded daemon config. pub fn from_config(config: &TvctlConfig) -> Self { diff --git a/tests/http_api.rs b/tests/http_api.rs index c18204b..340b321 100644 --- a/tests/http_api.rs +++ b/tests/http_api.rs @@ -110,10 +110,9 @@ impl TestDaemon { .get(format!("{}/daemon/status", self.base_url)) .send() .await + && response.status().is_success() { - if response.status().is_success() { - return; - } + return; } sleep(Duration::from_millis(100)).await; }