feat: add milestone 6 CLI polish

Add shell completion generation, improve first-run device messaging,
and include HTTP endpoint details in daemon startup output.
This commit is contained in:
44r0n7
2026-04-15 15:56:21 -04:00
parent b8a0a0ff16
commit 26cc0c973a
10 changed files with 164 additions and 111 deletions
Generated
+10
View File
@@ -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",
+1
View File
@@ -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"] }
+13
View File
@@ -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
```
---
+12 -10
View File
@@ -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 5HTTP API**
HTTP route parity with the daemon is now in progress. Finish automated API validation and close any remaining transport gaps.
**Milestone 6Polish 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
---
+37 -42
View File
@@ -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<String>, State(daemon): State<SharedDaemon
}
async fn get_state(Path(id): Path<String>, State(daemon): State<SharedDaemon>) -> Response {
execute_json::<StateResult>(
daemon,
DaemonRequest::GetState {
device: Some(id),
},
)
.await
execute_json::<StateResult>(daemon, DaemonRequest::GetState { device: Some(id) }).await
}
async fn list_apps(Path(id): Path<String>, State(daemon): State<SharedDaemon>) -> Response {
@@ -151,13 +146,7 @@ async fn launch_app(
}
async fn stop_app(Path(id): Path<String>, State(daemon): State<SharedDaemon>) -> 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<String>, State(daemon): State<SharedDaemon>) -> 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<String>, State(daemon): State<SharedDaemon>) -> Response {
execute_json::<DevLogsResult>(
daemon,
DaemonRequest::DevLogs {
device: Some(id),
},
)
.await
execute_json::<DevLogsResult>(daemon, DaemonRequest::DevLogs { device: Some(id) }).await
}
async fn daemon_status(State(daemon): State<SharedDaemon>) -> 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<SharedDaemon>) -> 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<TvKey, Response> {
fn parse_key(input: &str) -> Result<TvKey, Box<Response>> {
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,7 +509,10 @@ async fn send_daemon_request(
})?;
let mut response_bytes = Vec::new();
stream.read_to_end(&mut response_bytes).await.map_err(|error| {
stream
.read_to_end(&mut response_bytes)
.await
.map_err(|error| {
api_error(
StatusCode::INTERNAL_SERVER_ERROR,
"daemon_read_failed",
+67 -14
View File
@@ -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<G: Generator>(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) {
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 <ip>` 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 <ip>`.".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" };
+1 -13
View File
@@ -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<Self> {
+4 -4
View File
@@ -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<Option<JoinHandle<()>>> {
async fn start_http_server_if_enabled(
daemon: SharedDaemon,
) -> anyhow::Result<Option<JoinHandle<()>>> {
let (enabled, host, port) = {
let guard = daemon.lock().await;
(
@@ -469,8 +471,7 @@ 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 {
if clear && let Err(error) = guard.app_cache.clear_platform(&device.platform).await {
return (
DaemonResponse::error(
"app_cache_clear_failed",
@@ -480,7 +481,6 @@ pub(crate) async fn execute_request(
false,
);
}
}
match guard.adapters.list_apps(&device).await {
Ok(apps) => match guard
.app_cache
+1 -9
View File
@@ -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 {
+1 -2
View File
@@ -110,11 +110,10 @@ impl TestDaemon {
.get(format!("{}/daemon/status", self.base_url))
.send()
.await
&& response.status().is_success()
{
if response.status().is_success() {
return;
}
}
sleep(Duration::from_millis(100)).await;
}
panic!("daemon HTTP API did not become ready");