From 02386b89a193b121a24d55415f0c63ae2f06e7da Mon Sep 17 00:00:00 2001 From: 514sid Date: Sat, 6 Jun 2026 00:48:38 +0400 Subject: [PATCH 01/14] Add global --output flag with table, json, and csv support Replaces the per-subcommand --json flag scattered across 14 variants with a single global -o/--output flag on the root command. CSV output uses the csv crate for RFC-4180-compliant formatting. --- Cargo.lock | 1 + Cargo.toml | 1 + README.md | 28 ++++++++ docs/CommandLineHelp.md | 81 ++++++---------------- src/cli.rs | 150 +++++++++++++++------------------------- src/commands/mod.rs | 30 ++++++++ 6 files changed, 138 insertions(+), 153 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9b43783..451512c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2950,6 +2950,7 @@ dependencies = [ "anyhow", "clap", "clap-markdown", + "csv", "dirs", "envtestkit", "futures", diff --git a/Cargo.toml b/Cargo.toml index af3e0f5..7882704 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ path = "src/main.rs" [dependencies] anyhow = "1.0.65" clap = { version = "4.0.17", features = ["derive", "cargo"] } +csv = "1" clap-markdown = "0.1.4" dirs = "6.0.0" futures = "0.3.28" diff --git a/README.md b/README.md index fc60be8..7d767bc 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,34 @@ $ API_SERVER_NAME=local cargo build --release Explore available commands [here](https://developer.screenly.io/cli/#commands). +## Output Formats + +All list and get commands support three output formats via the global `--output` (`-o`) flag: + +| Format | Flag | Description | +|--------|------|-------------| +| Table | `--output table` | Human-readable table (default) | +| JSON | `--output json` | JSON output | +| CSV | `--output csv` | CSV output, suitable for piping to files or other tools | + +```bash +# Human-readable table (default) +$ screenly screen list + +# JSON output +$ screenly --output json asset list + +# CSV output saved to a file +$ screenly --output csv screen list > screens.csv +``` + +> [!NOTE] +> In debug builds, the CLI outputs log messages to stdout. Use `RUST_LOG=off` to suppress them +> when redirecting output to a file: +> ```bash +> $ RUST_LOG=off screenly --output csv screen list > screens.csv +> ``` + ## MCP Server (AI Assistant Integration) The Screenly CLI includes a built-in [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server, enabling AI assistants like Claude, Cursor, and others to interact with your Screenly digital signage network. diff --git a/docs/CommandLineHelp.md b/docs/CommandLineHelp.md index 797429b..13e9d01 100644 --- a/docs/CommandLineHelp.md +++ b/docs/CommandLineHelp.md @@ -66,7 +66,18 @@ Command line interface is intended for quick interaction with Screenly through t ###### **Options:** -* `-j`, `--json` — Enables JSON output +* `-o`, `--output ` — Output format: table (default), json, or csv + + Default value: `table` + + Possible values: + - `table`: + Human-readable table (default) + - `json`: + JSON output + - `csv`: + CSV output + @@ -105,11 +116,7 @@ Screen related commands Lists your screens -**Usage:** `screenly screen list [OPTIONS]` - -###### **Options:** - -* `-j`, `--json` — Enables JSON output +**Usage:** `screenly screen list` @@ -117,33 +124,25 @@ Lists your screens Gets a single screen by id -**Usage:** `screenly screen get [OPTIONS] ` +**Usage:** `screenly screen get ` ###### **Arguments:** * `` — UUID of the screen -###### **Options:** - -* `-j`, `--json` — Enables JSON output - ## `screenly screen add` Adds a new screen -**Usage:** `screenly screen add [OPTIONS] [NAME]` +**Usage:** `screenly screen add [NAME]` ###### **Arguments:** * `` — Pin code created with registrations endpoint * `` — Optional name of the new screen -###### **Options:** - -* `-j`, `--json` — Enables JSON output - ## `screenly screen delete` @@ -182,11 +181,7 @@ Asset related commands Lists your assets -**Usage:** `screenly asset list [OPTIONS]` - -###### **Options:** - -* `-j`, `--json` — Enables JSON output +**Usage:** `screenly asset list` @@ -194,33 +189,25 @@ Lists your assets Gets a single asset by id -**Usage:** `screenly asset get [OPTIONS] ` +**Usage:** `screenly asset get ` ###### **Arguments:** * `` — UUID of the asset -###### **Options:** - -* `-j`, `--json` — Enables JSON output - ## `screenly asset add` Adds a new asset -**Usage:** `screenly asset add [OPTIONS] ` +**Usage:** `screenly asset add <PATH> <TITLE>` ###### **Arguments:** * `<PATH>` — Path to local file or URL for remote file * `<TITLE>` — Asset title -###### **Options:** - -* `-j`, `--json` — Enables JSON output - ## `screenly asset delete` @@ -332,7 +319,7 @@ Time reference (ms): 32400000=9AM, 43200000=12PM, 61200000=5PM Examples: TRUE - Always show $WEEKDAY IN {1, 2, 3, 4, 5} - Weekdays only $TIME BETWEEN {32400000, 61200000} - 9 AM to 5 PM NOT $WEEKDAY IN {0, 6} - Exclude weekends -**Usage:** `screenly playlist create [OPTIONS] <TITLE> [PREDICATE]` +**Usage:** `screenly playlist create <TITLE> [PREDICATE]` ###### **Arguments:** @@ -357,21 +344,13 @@ Examples: TRUE - Always show $WEEKDAY IN {1, Default: TRUE -###### **Options:** - -* `-j`, `--json` — Enables JSON output - ## `screenly playlist list` Lists your playlists -**Usage:** `screenly playlist list [OPTIONS]` - -###### **Options:** - -* `-j`, `--json` — Enables JSON output +**Usage:** `screenly playlist list` @@ -403,7 +382,7 @@ Deletes a playlist. This cannot be undone Adds an asset to the end of the playlist -**Usage:** `screenly playlist append [OPTIONS] <UUID> <ASSET_UUID> [DURATION]` +**Usage:** `screenly playlist append <UUID> <ASSET_UUID> [DURATION]` ###### **Arguments:** @@ -411,17 +390,13 @@ Adds an asset to the end of the playlist * `<ASSET_UUID>` — UUID of the asset * `<DURATION>` — Duration of the playlist item in seconds. Defaults to 15 seconds -###### **Options:** - -* `-j`, `--json` — Enables JSON output - ## `screenly playlist prepend` Adds an asset to the beginning of the playlist -**Usage:** `screenly playlist prepend [OPTIONS] <UUID> <ASSET_UUID> [DURATION]` +**Usage:** `screenly playlist prepend <UUID> <ASSET_UUID> [DURATION]` ###### **Arguments:** @@ -429,10 +404,6 @@ Adds an asset to the beginning of the playlist * `<ASSET_UUID>` — UUID of the asset * `<DURATION>` — Duration of the playlist item in seconds. Defaults to 15 seconds -###### **Options:** - -* `-j`, `--json` — Enables JSON output - ## `screenly playlist update` @@ -482,11 +453,7 @@ Creates an Edge App in the store Lists your Edge Apps -**Usage:** `screenly edge-app list [OPTIONS]` - -###### **Options:** - -* `-j`, `--json` — Enables JSON output +**Usage:** `screenly edge-app list` @@ -539,7 +506,6 @@ Lists Edge App settings ###### **Options:** * `-p`, `--path <PATH>` — Path to the directory with the manifest. Defaults to the current working directory -* `-j`, `--json` — Enables JSON output @@ -583,7 +549,6 @@ Lists Edge App instances ###### **Options:** * `-p`, `--path <PATH>` — Path to the directory with the manifest. Defaults to the current working directory -* `-j`, `--json` — Enables JSON output diff --git a/src/cli.rs b/src/cli.rs index d3ba88e..8749a24 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -56,6 +56,17 @@ fn parse_key_val(s: &str) -> Result<(String, String), ParseError> { Ok((s[..pos].to_string(), s[pos + 1..].to_string())) } +#[derive(clap::ValueEnum, Clone, Debug, Default, PartialEq)] +pub enum OutputFormat { + /// Human-readable table (default). + #[default] + Table, + /// JSON output. + Json, + /// CSV output. + Csv, +} + #[derive(Parser)] #[command( version, @@ -64,9 +75,9 @@ fn parse_key_val(s: &str) -> Result<(String, String), ParseError> { )] #[command(propagate_version = true)] pub struct Cli { - /// Enables JSON output. - #[arg(short, long, action = clap::ArgAction::SetTrue)] - json: Option<bool>, + /// Output format: table (default), json, or csv. + #[arg(short, long, value_enum, default_value_t = OutputFormat::Table, global = true)] + pub output: OutputFormat, #[command(subcommand)] pub(crate) command: Commands, @@ -100,24 +111,14 @@ pub enum Commands { #[derive(Subcommand, Clone, PartialEq, Eq, PartialOrd, Ord)] pub enum ScreenCommands { /// Lists your screens. - List { - /// Enables JSON output. - #[arg(short, long, action = clap::ArgAction::SetTrue)] - json: Option<bool>, - }, + List {}, /// Gets a single screen by id. Get { - /// Enables JSON output. - #[arg(short, long, action = clap::ArgAction::SetTrue)] - json: Option<bool>, /// UUID of the screen. uuid: String, }, /// Adds a new screen. Add { - /// Enables JSON output. - #[arg(short, long, action = clap::ArgAction::SetTrue)] - json: Option<bool>, /// Pin code created with registrations endpoint. pin: String, /// Optional name of the new screen. @@ -152,9 +153,6 @@ pub enum PlaylistCommands { /// $TIME BETWEEN {32400000, 61200000} - 9 AM to 5 PM /// NOT $WEEKDAY IN {0, 6} - Exclude weekends Create { - /// Enables JSON output. - #[arg(short, long, action = clap::ArgAction::SetTrue)] - json: Option<bool>, /// Title of the new playlist. title: String, /// Predicate expression controlling when the playlist is shown. @@ -178,11 +176,7 @@ pub enum PlaylistCommands { predicate: Option<String>, }, /// Lists your playlists. - List { - /// Enables JSON output. - #[arg(short, long, action = clap::ArgAction::SetTrue)] - json: Option<bool>, - }, + List {}, /// Gets a single playlist by id. Get { /// UUID of the playlist. @@ -195,9 +189,6 @@ pub enum PlaylistCommands { }, /// Adds an asset to the end of the playlist. Append { - /// Enables JSON output. - #[arg(short, long, action = clap::ArgAction::SetTrue)] - json: Option<bool>, /// UUID of the playlist. uuid: String, /// UUID of the asset. @@ -207,9 +198,6 @@ pub enum PlaylistCommands { }, /// Adds an asset to the beginning of the playlist. Prepend { - /// Enables JSON output. - #[arg(short, long, action = clap::ArgAction::SetTrue)] - json: Option<bool>, /// UUID of the playlist. uuid: String, /// UUID of the asset. @@ -265,24 +253,14 @@ fn parse_key_values<T: KeyValuePairs>(s: &str) -> Result<T, ParseError> { #[derive(Subcommand, Clone, PartialEq, Eq, PartialOrd, Ord)] pub enum AssetCommands { /// Lists your assets. - List { - /// Enables JSON output. - #[arg(short, long, action = clap::ArgAction::SetTrue)] - json: Option<bool>, - }, + List {}, /// Gets a single asset by id. Get { - /// Enables JSON output. - #[arg(short, long, action = clap::ArgAction::SetTrue)] - json: Option<bool>, /// UUID of the asset. uuid: String, }, /// Adds a new asset. Add { - /// Enables JSON output. - #[arg(short, long, action = clap::ArgAction::SetTrue)] - json: Option<bool>, /// Path to local file or URL for remote file. path: String, /// Asset title. @@ -365,11 +343,7 @@ pub enum EdgeAppCommands { }, /// Lists your Edge Apps. - List { - /// Enables JSON output. - #[arg(short, long, action = clap::ArgAction::SetTrue)] - json: Option<bool>, - }, + List {}, /// Renames an Edge App. Rename { /// Path to the directory with the manifest. Defaults to the current working directory. @@ -435,10 +409,6 @@ pub enum EdgeAppSettingsCommands { /// Path to the directory with the manifest. Defaults to the current working directory. #[arg(short, long)] path: Option<String>, - - /// Enables JSON output. - #[arg(short, long, action = clap::ArgAction::SetTrue)] - json: Option<bool>, }, /// Sets an Edge App setting. Set { @@ -459,10 +429,6 @@ pub enum EdgeAppInstanceCommands { /// Path to the directory with the manifest. Defaults to the current working directory. #[arg(short, long)] path: Option<String>, - - /// Enables JSON output. - #[arg(short, long, action = clap::ArgAction::SetTrue)] - json: Option<bool>, }, /// Creates an Edge App instance. Create { @@ -490,14 +456,14 @@ pub enum EdgeAppInstanceCommands { pub fn handle_command_execution_result<T: Formatter>( result: anyhow::Result<T, CommandError>, - json: &Option<bool>, + output: &OutputFormat, ) { match result { Ok(screen) => { - let output_type = if json.unwrap_or(false) { - OutputType::Json - } else { - OutputType::HumanReadable + let output_type = match output { + OutputFormat::Json => OutputType::Json, + OutputFormat::Csv => OutputType::Csv, + OutputFormat::Table => OutputType::HumanReadable, }; println!("{}", screen.format(output_type)); } @@ -585,10 +551,10 @@ pub fn handle_cli(cli: &Cli) { }, } } - Commands::Screen(command) => handle_cli_screen_command(command), - Commands::Asset(command) => handle_cli_asset_command(command), - Commands::EdgeApp(command) => handle_cli_edge_app_command(command), - Commands::Playlist(command) => handle_cli_playlist_command(command), + Commands::Screen(command) => handle_cli_screen_command(command, &cli.output), + Commands::Asset(command) => handle_cli_asset_command(command, &cli.output), + Commands::EdgeApp(command) => handle_cli_edge_app_command(command, &cli.output), + Commands::Playlist(command) => handle_cli_playlist_command(command, &cli.output), Commands::Logout {} => { Authentication::remove_token().expect("Failed to remove token."); info!("Logout successful."); @@ -635,19 +601,19 @@ fn get_user_input() -> String { user_input.trim().to_string() } -pub fn handle_cli_screen_command(command: &ScreenCommands) { +pub fn handle_cli_screen_command(command: &ScreenCommands, output: &OutputFormat) { let authentication = get_authentication(); let screen_command = commands::screen::ScreenCommand::new(authentication); match command { - ScreenCommands::List { json } => { - handle_command_execution_result(screen_command.list(), json); + ScreenCommands::List {} => { + handle_command_execution_result(screen_command.list(), output); } - ScreenCommands::Get { uuid, json } => { - handle_command_execution_result(screen_command.get(uuid), json); + ScreenCommands::Get { uuid } => { + handle_command_execution_result(screen_command.get(uuid), output); } - ScreenCommands::Add { pin, name, json } => { - handle_command_execution_result(screen_command.add(pin, name.clone()), json); + ScreenCommands::Add { pin, name } => { + handle_command_execution_result(screen_command.add(pin, name.clone()), output); } ScreenCommands::Delete { uuid } => { match get_screen_name(uuid, &screen_command) { @@ -679,21 +645,17 @@ pub fn handle_cli_screen_command(command: &ScreenCommands) { } } -pub fn handle_cli_playlist_command(command: &PlaylistCommands) { +pub fn handle_cli_playlist_command(command: &PlaylistCommands, output: &OutputFormat) { let playlist_command = PlaylistCommand::new(get_authentication()); match command { - PlaylistCommands::Create { - json, - title, - predicate, - } => { + PlaylistCommands::Create { title, predicate } => { handle_command_execution_result( playlist_command.create(title, &predicate.clone().unwrap_or("TRUE".to_owned())), - json, + output, ); } - PlaylistCommands::List { json } => { - handle_command_execution_result(playlist_command.list(), json); + PlaylistCommands::List {} => { + handle_command_execution_result(playlist_command.list(), output); } PlaylistCommands::Get { uuid } => { let playlist_file = playlist_command.get_playlist_file(uuid); @@ -716,7 +678,6 @@ pub fn handle_cli_playlist_command(command: &PlaylistCommands) { } }, PlaylistCommands::Append { - json, uuid, asset_uuid, duration, @@ -727,11 +688,10 @@ pub fn handle_cli_playlist_command(command: &PlaylistCommands) { asset_uuid, (*duration).unwrap_or(DEFAULT_ASSET_DURATION), ), - json, + output, ); } PlaylistCommands::Prepend { - json, uuid, asset_uuid, duration, @@ -742,7 +702,7 @@ pub fn handle_cli_playlist_command(command: &PlaylistCommands) { asset_uuid, (*duration).unwrap_or(DEFAULT_ASSET_DURATION), ), - json, + output, ); } PlaylistCommands::Update {} => { @@ -765,19 +725,19 @@ pub fn handle_cli_playlist_command(command: &PlaylistCommands) { } } -pub fn handle_cli_asset_command(command: &AssetCommands) { +pub fn handle_cli_asset_command(command: &AssetCommands, output: &OutputFormat) { let authentication = get_authentication(); let asset_command = commands::asset::AssetCommand::new(authentication); match command { - AssetCommands::List { json } => { - handle_command_execution_result(asset_command.list(), json); + AssetCommands::List {} => { + handle_command_execution_result(asset_command.list(), output); } - AssetCommands::Get { uuid, json } => { - handle_command_execution_result(asset_command.get(uuid), json); + AssetCommands::Get { uuid } => { + handle_command_execution_result(asset_command.get(uuid), output); } - AssetCommands::Add { path, title, json } => { - handle_command_execution_result(asset_command.add(path, title), json); + AssetCommands::Add { path, title } => { + handle_command_execution_result(asset_command.add(path, title), output); } AssetCommands::Delete { uuid } => { match get_asset_title(uuid, &asset_command) { @@ -906,7 +866,7 @@ pub fn handle_cli_asset_command(command: &AssetCommands) { } } -pub fn handle_cli_edge_app_command(command: &EdgeAppCommands) { +pub fn handle_cli_edge_app_command(command: &EdgeAppCommands, output: &OutputFormat) { let authentication = get_authentication(); let edge_app_command = commands::edge_app::EdgeAppCommand::new(authentication); @@ -946,8 +906,8 @@ pub fn handle_cli_edge_app_command(command: &EdgeAppCommands) { } } - EdgeAppCommands::List { json } => { - handle_command_execution_result(edge_app_command.list(), json); + EdgeAppCommands::List {} => { + handle_command_execution_result(edge_app_command.list(), output); } EdgeAppCommands::Deploy { path, @@ -962,8 +922,8 @@ pub fn handle_cli_edge_app_command(command: &EdgeAppCommands) { } }, EdgeAppCommands::Setting(command) => match command { - EdgeAppSettingsCommands::List { path, json } => { - handle_command_execution_result(edge_app_command.list_settings(path.clone()), json); + EdgeAppSettingsCommands::List { path } => { + handle_command_execution_result(edge_app_command.list_settings(path.clone()), output); } EdgeAppSettingsCommands::Set { setting_pair, path } => { match edge_app_command.set_setting(path.clone(), &setting_pair.0, &setting_pair.1) { @@ -1155,7 +1115,7 @@ pub fn handle_cli_edge_app_command(command: &EdgeAppCommands) { } } EdgeAppCommands::Instance(command) => match command { - EdgeAppInstanceCommands::List { path, json } => { + EdgeAppInstanceCommands::List { path } => { let actual_app_id = match edge_app_command.get_app_id(path.clone()) { Ok(id) => id, Err(e) => { @@ -1165,7 +1125,7 @@ pub fn handle_cli_edge_app_command(command: &EdgeAppCommands) { }; handle_command_execution_result( edge_app_command.list_instances(&actual_app_id), - json, + output, ); } EdgeAppInstanceCommands::Create { path, name } => { diff --git a/src/commands/mod.rs b/src/commands/mod.rs index db4f605..f4494ee 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -22,6 +22,7 @@ pub(crate) mod serde_utils; pub enum OutputType { HumanReadable, Json, + Csv, } pub trait Formatter { @@ -67,6 +68,35 @@ where table.to_string() } OutputType::Json => serde_json::to_string_pretty(&value.value()).unwrap(), + OutputType::Csv => { + let mut wtr = csv::WriterBuilder::new().from_writer(vec![]); + wtr.write_record(&column_names).unwrap(); + if let Some(values) = value.value().as_array() { + for v in values { + let row: Vec<String> = field_names + .iter() + .map(|field| { + let fv = &v[field]; + if let Some(s) = fv.as_str() { + s.to_string() + } else if let Some(b) = fv.as_bool() { + b.to_string() + } else if let Some(n) = fv.as_u64() { + n.to_string() + } else if let Some(n) = fv.as_f64() { + n.to_string() + } else if fv.is_null() { + String::new() + } else { + fv.to_string() + } + }) + .collect(); + wtr.write_record(&row).unwrap(); + } + } + String::from_utf8(wtr.into_inner().unwrap()).unwrap() + } } } From c8b5d1f66fe3f9a46c245ed6cc3abd693b54dcd8 Mon Sep 17 00:00:00 2001 From: 514sid <iam@514sid.com> Date: Sat, 6 Jun 2026 02:45:19 +0400 Subject: [PATCH 02/14] Restrict CSV output to supported commands only Add a `supports_csv()` opt-in method to the `Formatter` trait (default false). Flat formatters (Screens, Assets, Playlists, PlaylistItems, EdgeApps, EdgeAppInstances) override it to true. Commands that return nested data (EdgeAppSettings) stay false. `handle_command_execution_result` checks this before processing and exits with a clear error message. --- src/cli.rs | 4 ++++ src/commands/mod.rs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/src/cli.rs b/src/cli.rs index 8749a24..128bab1 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -458,6 +458,10 @@ pub fn handle_command_execution_result<T: Formatter>( result: anyhow::Result<T, CommandError>, output: &OutputFormat, ) { + if *output == OutputFormat::Csv && !T::supports_csv() { + error!("CSV output is not supported for this command. Use --output table or --output json instead."); + std::process::exit(1); + } match result { Ok(screen) => { let output_type = match output { diff --git a/src/commands/mod.rs b/src/commands/mod.rs index f4494ee..448a21e 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -27,6 +27,12 @@ pub enum OutputType { pub trait Formatter { fn format(&self, output_type: OutputType) -> String; + fn supports_csv() -> bool + where + Self: Sized, + { + false + } } pub trait FormatterValue { @@ -326,6 +332,10 @@ impl Formatter for EdgeApps { None::<fn(&str, &serde_json::Value) -> Cell>, ) } + + fn supports_csv() -> bool { + true + } } #[derive(Debug)] @@ -410,6 +420,10 @@ impl Formatter for EdgeAppInstances { ), ) } + + fn supports_csv() -> bool { + true + } } #[derive(Debug)] @@ -439,6 +453,10 @@ impl Formatter for Assets { None::<fn(&str, &serde_json::Value) -> Cell>, ) } + + fn supports_csv() -> bool { + true + } } #[derive(Debug)] @@ -459,6 +477,10 @@ impl FormatterValue for Screens { } impl Formatter for Screens { + fn supports_csv() -> bool { + true + } + fn format(&self, output_type: OutputType) -> String { fn format_boolean_field(value: &serde_json::Value) -> Cell { if value.as_bool().unwrap_or(false) { @@ -527,6 +549,10 @@ impl FormatterValue for Playlists { } impl Formatter for Playlists { + fn supports_csv() -> bool { + true + } + fn format(&self, output_type: OutputType) -> String { fn format_boolean_field(value: &serde_json::Value) -> Cell { if value.as_bool().unwrap_or(false) { @@ -570,6 +596,10 @@ impl FormatterValue for PlaylistItems { } impl Formatter for PlaylistItems { + fn supports_csv() -> bool { + true + } + fn format(&self, output_type: OutputType) -> String { format_value( output_type, From fe5519c0e55061cbd8536c34c58eca3fc3f28cef Mon Sep 17 00:00:00 2001 From: 514sid <iam@514sid.com> Date: Sat, 6 Jun 2026 02:45:22 +0400 Subject: [PATCH 03/14] Add JSON file output examples to README --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 7d767bc..983b48e 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,9 @@ $ screenly --output json asset list # CSV output saved to a file $ screenly --output csv screen list > screens.csv + +# JSON output saved to a file +$ screenly --output json screen list > screens.json ``` > [!NOTE] @@ -93,6 +96,7 @@ $ screenly --output csv screen list > screens.csv > when redirecting output to a file: > ```bash > $ RUST_LOG=off screenly --output csv screen list > screens.csv +> $ RUST_LOG=off screenly --output json screen list > screens.json > ``` ## MCP Server (AI Assistant Integration) From 3810a443876cc267930167a25b4e3abb663070dd Mon Sep 17 00:00:00 2001 From: 514sid <iam@514sid.com> Date: Sat, 6 Jun 2026 02:48:04 +0400 Subject: [PATCH 04/14] Add deprecated --json flag fallback with warning Users on the old --json per-subcommand flag now get a deprecation warning and JSON output instead of a clap error, easing migration to --output json. --- src/cli.rs | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 128bab1..9b0af79 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -4,7 +4,7 @@ use std::{env, fs, io}; use clap::{Parser, Subcommand}; use http_auth_basic::Credentials; -use log::{error, info}; +use log::{error, info, warn}; use reqwest::StatusCode; use rpassword::read_password; use thiserror::Error; @@ -79,6 +79,10 @@ pub struct Cli { #[arg(short, long, value_enum, default_value_t = OutputFormat::Table, global = true)] pub output: OutputFormat, + /// Deprecated: use --output json instead. + #[arg(long, hide = true, global = true, conflicts_with = "output")] + pub json: bool, + #[command(subcommand)] pub(crate) command: Commands, } @@ -532,6 +536,13 @@ pub fn get_asset_title( } pub fn handle_cli(cli: &Cli) { + let output = if cli.json { + warn!("--json is deprecated, use --output json instead."); + &OutputFormat::Json + } else { + &cli.output + }; + match &cli.command { Commands::Login {} => { print!("Enter your API Token: "); @@ -555,10 +566,10 @@ pub fn handle_cli(cli: &Cli) { }, } } - Commands::Screen(command) => handle_cli_screen_command(command, &cli.output), - Commands::Asset(command) => handle_cli_asset_command(command, &cli.output), - Commands::EdgeApp(command) => handle_cli_edge_app_command(command, &cli.output), - Commands::Playlist(command) => handle_cli_playlist_command(command, &cli.output), + Commands::Screen(command) => handle_cli_screen_command(command, output), + Commands::Asset(command) => handle_cli_asset_command(command, output), + Commands::EdgeApp(command) => handle_cli_edge_app_command(command, output), + Commands::Playlist(command) => handle_cli_playlist_command(command, output), Commands::Logout {} => { Authentication::remove_token().expect("Failed to remove token."); info!("Logout successful."); From 1f1d2d8e87fcb3ba5e857a3d242ee7b770a1de37 Mon Sep 17 00:00:00 2001 From: 514sid <iam@514sid.com> Date: Sat, 6 Jun 2026 02:59:37 +0400 Subject: [PATCH 05/14] Fix rustfmt formatting --- src/cli.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/cli.rs b/src/cli.rs index 9b0af79..8aa789e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -938,7 +938,10 @@ pub fn handle_cli_edge_app_command(command: &EdgeAppCommands, output: &OutputFor }, EdgeAppCommands::Setting(command) => match command { EdgeAppSettingsCommands::List { path } => { - handle_command_execution_result(edge_app_command.list_settings(path.clone()), output); + handle_command_execution_result( + edge_app_command.list_settings(path.clone()), + output, + ); } EdgeAppSettingsCommands::Set { setting_pair, path } => { match edge_app_command.set_setting(path.clone(), &setting_pair.0, &setting_pair.1) { From f04f76866cca561c76aade62179642dd329608e8 Mon Sep 17 00:00:00 2001 From: 514sid <iam@514sid.com> Date: Mon, 8 Jun 2026 13:35:10 +0400 Subject: [PATCH 06/14] Migrate playlist get through handle_command_execution_result playlist get was hand-rolling serde_json::to_string_pretty and ignoring --output entirely. Add Formatter impl for PlaylistFile (table, json, csv) and route the handler through handle_command_execution_result so all three output formats work consistently with the rest of the get/list commands. --- src/cli.rs | 11 +---------- src/commands/mod.rs | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 8aa789e..ebd4b43 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -673,16 +673,7 @@ pub fn handle_cli_playlist_command(command: &PlaylistCommands, output: &OutputFo handle_command_execution_result(playlist_command.list(), output); } PlaylistCommands::Get { uuid } => { - let playlist_file = playlist_command.get_playlist_file(uuid); - match playlist_file { - Ok(playlist) => { - let pretty_playlist_file = serde_json::to_string_pretty(&playlist).unwrap(); - println!("{pretty_playlist_file}"); - } - Err(e) => { - eprintln!("Error occurred when getting playlist: {e:?}") - } - } + handle_command_execution_result(playlist_command.get_playlist_file(uuid), output); } PlaylistCommands::Delete { uuid } => match playlist_command.delete(uuid) { Ok(()) => { diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 448a21e..a8568ab 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -311,6 +311,44 @@ impl PlaylistFile { } } +impl Formatter for PlaylistFile { + fn supports_csv() -> bool { + true + } + + fn format(&self, output_type: OutputType) -> String { + match output_type { + OutputType::Json => serde_json::to_string_pretty(self).unwrap(), + OutputType::HumanReadable => { + let mut table = prettytable::Table::new(); + table.add_row(Row::from(vec!["Asset Id", "Duration"])); + for item in &self.items { + table.add_row(Row::new(vec![ + Cell::new(&item.asset_id), + Cell::new( + &indicatif::HumanDuration(Duration::from_secs(item.duration as u64)) + .to_string(), + ), + ])); + } + table.to_string() + } + OutputType::Csv => { + let mut wtr = csv::WriterBuilder::new().from_writer(vec![]); + wtr.write_record(["asset_id", "duration"]).unwrap(); + for item in &self.items { + wtr.write_record([ + item.asset_id.as_str(), + &item.duration.to_string(), + ]) + .unwrap(); + } + String::from_utf8(wtr.into_inner().unwrap()).unwrap() + } + } + } +} + impl EdgeApps { pub fn new(value: serde_json::Value) -> Self { Self { value } From fb91a34b643dcdc084a97b9d0351c6077f14c1db Mon Sep 17 00:00:00 2001 From: 514sid <iam@514sid.com> Date: Mon, 8 Jun 2026 13:35:28 +0400 Subject: [PATCH 07/14] Use eprintln! for --json deprecation warning warn! routes through env_logger, so RUST_LOG=off (which the README recommends for clean CSV/JSON pipes) silently swallows the warning. Users migrating from --json won't see it in exactly the scenario where they're most likely to hit it. Switch to eprintln! so the warning always reaches stderr regardless of log level. --- src/cli.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index ebd4b43..98eb3d3 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -4,7 +4,7 @@ use std::{env, fs, io}; use clap::{Parser, Subcommand}; use http_auth_basic::Credentials; -use log::{error, info, warn}; +use log::{error, info}; use reqwest::StatusCode; use rpassword::read_password; use thiserror::Error; @@ -537,7 +537,7 @@ pub fn get_asset_title( pub fn handle_cli(cli: &Cli) { let output = if cli.json { - warn!("--json is deprecated, use --output json instead."); + eprintln!("Warning: --json is deprecated, use --output json instead."); &OutputFormat::Json } else { &cli.output From 8e38c891641f2d64552db0b99c46437e227ec457 Mon Sep 17 00:00:00 2001 From: 514sid <iam@514sid.com> Date: Mon, 8 Jun 2026 13:35:41 +0400 Subject: [PATCH 08/14] Fix negative integers rendered as floats in CSV output The numeric fallback chain as_u64 -> as_f64 caused negative integers to fail as_u64 and fall through to as_f64, producing "-1.0" instead of "-1". Insert as_i64 between as_u64 and as_f64 to handle negative integers correctly. --- src/commands/mod.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/commands/mod.rs b/src/commands/mod.rs index a8568ab..caa2915 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -89,6 +89,8 @@ where b.to_string() } else if let Some(n) = fv.as_u64() { n.to_string() + } else if let Some(n) = fv.as_i64() { + n.to_string() } else if let Some(n) = fv.as_f64() { n.to_string() } else if fv.is_null() { From bd6ba397b2dcdff5ba014ba03e828820626b23a2 Mon Sep 17 00:00:00 2001 From: 514sid <iam@514sid.com> Date: Mon, 8 Jun 2026 13:41:51 +0400 Subject: [PATCH 09/14] Derive Copy for OutputFormat, pass by value OutputFormat is a fieldless three-variant enum. Adding Copy removes the need to pass &OutputFormat through every handle_cli_*_command and handle_command_execution_result, and eliminates the *output dereferences and &OutputFormat::Json temporary references in handle_cli. --- src/cli.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 98eb3d3..9185431 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -56,7 +56,7 @@ fn parse_key_val(s: &str) -> Result<(String, String), ParseError> { Ok((s[..pos].to_string(), s[pos + 1..].to_string())) } -#[derive(clap::ValueEnum, Clone, Debug, Default, PartialEq)] +#[derive(clap::ValueEnum, Clone, Copy, Debug, Default, PartialEq)] pub enum OutputFormat { /// Human-readable table (default). #[default] @@ -460,9 +460,9 @@ pub enum EdgeAppInstanceCommands { pub fn handle_command_execution_result<T: Formatter>( result: anyhow::Result<T, CommandError>, - output: &OutputFormat, + output: OutputFormat, ) { - if *output == OutputFormat::Csv && !T::supports_csv() { + if output == OutputFormat::Csv && !T::supports_csv() { error!("CSV output is not supported for this command. Use --output table or --output json instead."); std::process::exit(1); } @@ -538,9 +538,9 @@ pub fn get_asset_title( pub fn handle_cli(cli: &Cli) { let output = if cli.json { eprintln!("Warning: --json is deprecated, use --output json instead."); - &OutputFormat::Json + OutputFormat::Json } else { - &cli.output + cli.output }; match &cli.command { @@ -616,7 +616,7 @@ fn get_user_input() -> String { user_input.trim().to_string() } -pub fn handle_cli_screen_command(command: &ScreenCommands, output: &OutputFormat) { +pub fn handle_cli_screen_command(command: &ScreenCommands, output: OutputFormat) { let authentication = get_authentication(); let screen_command = commands::screen::ScreenCommand::new(authentication); @@ -660,7 +660,7 @@ pub fn handle_cli_screen_command(command: &ScreenCommands, output: &OutputFormat } } -pub fn handle_cli_playlist_command(command: &PlaylistCommands, output: &OutputFormat) { +pub fn handle_cli_playlist_command(command: &PlaylistCommands, output: OutputFormat) { let playlist_command = PlaylistCommand::new(get_authentication()); match command { PlaylistCommands::Create { title, predicate } => { @@ -731,7 +731,7 @@ pub fn handle_cli_playlist_command(command: &PlaylistCommands, output: &OutputFo } } -pub fn handle_cli_asset_command(command: &AssetCommands, output: &OutputFormat) { +pub fn handle_cli_asset_command(command: &AssetCommands, output: OutputFormat) { let authentication = get_authentication(); let asset_command = commands::asset::AssetCommand::new(authentication); @@ -872,7 +872,7 @@ pub fn handle_cli_asset_command(command: &AssetCommands, output: &OutputFormat) } } -pub fn handle_cli_edge_app_command(command: &EdgeAppCommands, output: &OutputFormat) { +pub fn handle_cli_edge_app_command(command: &EdgeAppCommands, output: OutputFormat) { let authentication = get_authentication(); let edge_app_command = commands::edge_app::EdgeAppCommand::new(authentication); From d361c82d80500a4d28f6942ab4aacf238d7d82f6 Mon Sep 17 00:00:00 2001 From: 514sid <iam@514sid.com> Date: Mon, 8 Jun 2026 13:42:03 +0400 Subject: [PATCH 10/14] Narrow Cli output and json fields to pub(crate) Both fields are only accessed inside cli.rs (in handle_cli). main.rs only calls Cli::parse() and hands the struct to handle_cli, so pub visibility is wider than necessary. pub(crate) matches the existing visibility of the command field. --- src/cli.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 9185431..d20d121 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -77,11 +77,11 @@ pub enum OutputFormat { pub struct Cli { /// Output format: table (default), json, or csv. #[arg(short, long, value_enum, default_value_t = OutputFormat::Table, global = true)] - pub output: OutputFormat, + pub(crate) output: OutputFormat, /// Deprecated: use --output json instead. #[arg(long, hide = true, global = true, conflicts_with = "output")] - pub json: bool, + pub(crate) json: bool, #[command(subcommand)] pub(crate) command: Commands, From f52a8212f54ef8e16ecada04ae123e22e4719955 Mon Sep 17 00:00:00 2001 From: 514sid <iam@514sid.com> Date: Mon, 8 Jun 2026 13:42:36 +0400 Subject: [PATCH 11/14] Use unit variants for fieldless enum variants List {} and Update {} are idiomatic as unit variants (List, Update) since they carry no data. No behavior change. --- src/cli.rs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index d20d121..f2e6421 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -115,7 +115,7 @@ pub enum Commands { #[derive(Subcommand, Clone, PartialEq, Eq, PartialOrd, Ord)] pub enum ScreenCommands { /// Lists your screens. - List {}, + List, /// Gets a single screen by id. Get { /// UUID of the screen. @@ -180,7 +180,7 @@ pub enum PlaylistCommands { predicate: Option<String>, }, /// Lists your playlists. - List {}, + List, /// Gets a single playlist by id. Get { /// UUID of the playlist. @@ -210,7 +210,7 @@ pub enum PlaylistCommands { duration: Option<u32>, }, /// Updates a playlist from JSON input on stdin. - Update {}, + Update, } #[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)] @@ -257,7 +257,7 @@ fn parse_key_values<T: KeyValuePairs>(s: &str) -> Result<T, ParseError> { #[derive(Subcommand, Clone, PartialEq, Eq, PartialOrd, Ord)] pub enum AssetCommands { /// Lists your assets. - List {}, + List, /// Gets a single asset by id. Get { /// UUID of the asset. @@ -347,7 +347,7 @@ pub enum EdgeAppCommands { }, /// Lists your Edge Apps. - List {}, + List, /// Renames an Edge App. Rename { /// Path to the directory with the manifest. Defaults to the current working directory. @@ -621,7 +621,7 @@ pub fn handle_cli_screen_command(command: &ScreenCommands, output: OutputFormat) let screen_command = commands::screen::ScreenCommand::new(authentication); match command { - ScreenCommands::List {} => { + ScreenCommands::List => { handle_command_execution_result(screen_command.list(), output); } ScreenCommands::Get { uuid } => { @@ -669,7 +669,7 @@ pub fn handle_cli_playlist_command(command: &PlaylistCommands, output: OutputFor output, ); } - PlaylistCommands::List {} => { + PlaylistCommands::List => { handle_command_execution_result(playlist_command.list(), output); } PlaylistCommands::Get { uuid } => { @@ -711,7 +711,7 @@ pub fn handle_cli_playlist_command(command: &PlaylistCommands, output: OutputFor output, ); } - PlaylistCommands::Update {} => { + PlaylistCommands::Update => { let mut input = String::new(); io::stdin() .read_to_string(&mut input) @@ -736,7 +736,7 @@ pub fn handle_cli_asset_command(command: &AssetCommands, output: OutputFormat) { let asset_command = commands::asset::AssetCommand::new(authentication); match command { - AssetCommands::List {} => { + AssetCommands::List => { handle_command_execution_result(asset_command.list(), output); } AssetCommands::Get { uuid } => { @@ -912,7 +912,7 @@ pub fn handle_cli_edge_app_command(command: &EdgeAppCommands, output: OutputForm } } - EdgeAppCommands::List {} => { + EdgeAppCommands::List => { handle_command_execution_result(edge_app_command.list(), output); } EdgeAppCommands::Deploy { From 31ab7e812e3c33a2a948617f4cfb5fdbc2b32466 Mon Sep 17 00:00:00 2001 From: 514sid <iam@514sid.com> Date: Mon, 8 Jun 2026 13:42:50 +0400 Subject: [PATCH 12/14] Use print! for CSV output to avoid trailing newline csv::Writer already terminates each record with \r\n per RFC 4180. println! appended an extra \n, producing a blank line at the end of every CSV stream. Switch to print! for CSV output only; table and JSON keep println! since they don't embed their own line terminator. --- src/cli.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/cli.rs b/src/cli.rs index f2e6421..ca8d421 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -473,7 +473,12 @@ pub fn handle_command_execution_result<T: Formatter>( OutputFormat::Csv => OutputType::Csv, OutputFormat::Table => OutputType::HumanReadable, }; - println!("{}", screen.format(output_type)); + let formatted = screen.format(output_type); + if output == OutputFormat::Csv { + print!("{formatted}"); + } else { + println!("{formatted}"); + } } Err(e) => { match e { From c6aa5fa4f201188999806d7ca00726ab83525112 Mon Sep 17 00:00:00 2001 From: 514sid <iam@514sid.com> Date: Mon, 8 Jun 2026 13:43:24 +0400 Subject: [PATCH 13/14] Add tests for CSV formatting and --json flag behaviour - Assets CSV round-trip: verifies header row and RFC 4180 escaping for values containing commas and embedded quotes - EdgeAppSettings::supports_csv returns false: pins the guard that triggers the CSV-not-supported rejection path - --json sets cli.json and leaves output at the Table default - --json combined with --output is rejected by clap's conflicts_with --- src/cli.rs | 14 ++++++++++++++ src/commands/mod.rs | 24 ++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/src/cli.rs b/src/cli.rs index ca8d421..1509aa2 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1312,4 +1312,18 @@ mod tests { assert!(message.contains("Authentication error")); assert!(message.contains("Please run `screenly login` to authenticate")); } + + #[test] + fn test_json_flag_sets_json_field() { + let cli = Cli::try_parse_from(["screenly", "--json", "screen", "list"]).unwrap(); + assert!(cli.json); + assert_eq!(cli.output, OutputFormat::Table); + } + + #[test] + fn test_json_flag_conflicts_with_output_flag() { + let result = + Cli::try_parse_from(["screenly", "--json", "--output", "json", "screen", "list"]); + assert!(result.is_err()); + } } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index caa2915..fb69f26 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -664,6 +664,30 @@ impl Formatter for PlaylistItems { mod tests { use super::*; + #[test] + fn test_assets_csv_round_trip() { + let data = r#"[ + {"id": "abc-123", "title": "Plain Title", "type": "video/mp4", "status": "active"}, + {"id": "def-456", "title": "Comma, Title", "type": "image/png", "status": "active"}, + {"id": "ghi-789", "title": "Quote \"Title\"", "type": "image/jpeg", "status": "active"} + ]"#; + let assets = Assets::new(serde_json::from_str(data).unwrap()); + let output = assets.format(OutputType::Csv); + let mut lines = output.lines(); + assert_eq!(lines.next().unwrap(), "Id,Title,Type,Status"); + assert_eq!(lines.next().unwrap(), "abc-123,Plain Title,video/mp4,active"); + assert_eq!(lines.next().unwrap(), r#"def-456,"Comma, Title",image/png,active"#); + assert_eq!( + lines.next().unwrap(), + r#"ghi-789,"Quote ""Title""",image/jpeg,active"# + ); + } + + #[test] + fn test_edge_app_settings_does_not_support_csv() { + assert!(!EdgeAppSettings::supports_csv()); + } + #[test] fn test_edge_app_instance_formatter_format_output_properly() { let data = r#"[{ From cc757cc0e3765d318748c12d861344f67b8f5ee8 Mon Sep 17 00:00:00 2001 From: 514sid <iam@514sid.com> Date: Mon, 8 Jun 2026 13:48:27 +0400 Subject: [PATCH 14/14] Fix rustfmt formatting --- src/commands/mod.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/commands/mod.rs b/src/commands/mod.rs index fb69f26..8d9d0e3 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -339,11 +339,8 @@ impl Formatter for PlaylistFile { let mut wtr = csv::WriterBuilder::new().from_writer(vec![]); wtr.write_record(["asset_id", "duration"]).unwrap(); for item in &self.items { - wtr.write_record([ - item.asset_id.as_str(), - &item.duration.to_string(), - ]) - .unwrap(); + wtr.write_record([item.asset_id.as_str(), &item.duration.to_string()]) + .unwrap(); } String::from_utf8(wtr.into_inner().unwrap()).unwrap() } @@ -675,8 +672,14 @@ mod tests { let output = assets.format(OutputType::Csv); let mut lines = output.lines(); assert_eq!(lines.next().unwrap(), "Id,Title,Type,Status"); - assert_eq!(lines.next().unwrap(), "abc-123,Plain Title,video/mp4,active"); - assert_eq!(lines.next().unwrap(), r#"def-456,"Comma, Title",image/png,active"#); + assert_eq!( + lines.next().unwrap(), + "abc-123,Plain Title,video/mp4,active" + ); + assert_eq!( + lines.next().unwrap(), + r#"def-456,"Comma, Title",image/png,active"# + ); assert_eq!( lines.next().unwrap(), r#"ghi-789,"Quote ""Title""",image/jpeg,active"#