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
+44 -49
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,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::<DaemonResponse>(&response_bytes).map_err(|error| {
api_error(
+68 -15
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) {
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 <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> {
+12 -12
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,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
+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 {