From e215dd8b87cf13edb549096897adfe6ceba4992b Mon Sep 17 00:00:00 2001 From: Test User Date: Wed, 22 Jul 2026 11:48:30 +0200 Subject: [PATCH] Add cbd-cli crate: shared clap CLI definitions + executor stub Stage 2 of the CLI dev-flow (api-design). cbd-cli holds the clap Parser/Subcommand types for all three binaries (ServerCli/TuiCli/CbdCli, library/queue/global, guard/scan/auth, Role, RemoteArgs), asset generation (clap_complete + clap_mangen), and a feature-gated gRPC executor (run_remote) whose per-command dispatch is stubbed for the implement stage. Compiles with and without the client feature. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 38 ++++++ Cargo.toml | 4 + cbd-cli/Cargo.toml | 28 ++++ cbd-cli/src/client.rs | 72 ++++++++++ cbd-cli/src/lib.rs | 306 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 448 insertions(+) create mode 100644 cbd-cli/Cargo.toml create mode 100644 cbd-cli/src/client.rs create mode 100644 cbd-cli/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index f96ce35..0b4b066 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -664,6 +664,19 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "cbd-cli" +version = "0.1.0" +dependencies = [ + "base64", + "clap", + "clap_complete", + "clap_mangen", + "crabidy-core", + "tokio", + "tonic", +] + [[package]] name = "cbd-tui" version = "0.1.0" @@ -797,6 +810,15 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_complete" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8b397918185f0161ff3d6fcaa9e4bfc09b8367caf6e1d4a2848e5477ed027b" +dependencies = [ + "clap", +] + [[package]] name = "clap_derive" version = "4.6.1" @@ -815,6 +837,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "clap_mangen" +version = "0.2.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e30ffc187e2e3aeafcd1c6e2aa416e29739454c0ccaa419226d5ecd181f2d78" +dependencies = [ + "clap", + "roff", +] + [[package]] name = "cmake" version = "0.1.58" @@ -4119,6 +4151,12 @@ dependencies = [ "thiserror 2.0.19", ] +[[package]] +name = "roff" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189" + [[package]] name = "rquickjs" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index 5f02bb1..2b17e62 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ resolver = "2" members = [ "audio-player", "cbd", + "cbd-cli", "cbd-tui", "cbd-web", "crabidy-core", @@ -27,6 +28,8 @@ chrono = { version = "0.4", default-features = false, features = ["clock"] } axum = "0.8" clap = { version = "4", features = ["derive"] } clap-serde-derive = "0.2" +clap_complete = "4" +clap_mangen = "0.2" console_error_panic_hook = "0.1" crossterm = "0.29" dirs = "6" @@ -91,6 +94,7 @@ url = "2" # Local crates audio-player = { path = "audio-player" } +cbd-cli = { path = "cbd-cli" } cbd-tui = { path = "cbd-tui" } crabidy-core = { path = "crabidy-core" } crabidy-server = { path = "crabidy-server" } diff --git a/cbd-cli/Cargo.toml b/cbd-cli/Cargo.toml new file mode 100644 index 0000000..acfdb15 --- /dev/null +++ b/cbd-cli/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "cbd-cli" +version.workspace = true +edition.workspace = true + +[dependencies] +clap.workspace = true +clap_complete.workspace = true +clap_mangen.workspace = true + +# The `client` feature adds the gRPC executor for the remote +# library/queue/global commands. Kept optional so binaries' build.rs can +# depend on this crate (default features = clap only) without dragging in +# tonic on every build (architecture/cli.md D1/D7). +crabidy-core = { workspace = true, optional = true } +tonic = { workspace = true, optional = true, features = [ + "transport", + "codegen", +] } +tokio = { workspace = true, optional = true, features = [ + "rt-multi-thread", + "macros", +] } +base64 = { workspace = true, optional = true } + +[features] +default = [] +client = ["dep:crabidy-core", "dep:tonic", "dep:tokio", "dep:base64"] diff --git a/cbd-cli/src/client.rs b/cbd-cli/src/client.rs new file mode 100644 index 0000000..12f6882 --- /dev/null +++ b/cbd-cli/src/client.rs @@ -0,0 +1,72 @@ +//! The gRPC executor for the remote `library`/`queue`/`global` commands +//! (`client` feature). Connects a generated `crabidy-core` client with a +//! basic-auth interceptor and runs one command against a server. +//! +//! Stage-2 (api-design) note: the connection builder is real; `run_remote`'s +//! per-command dispatch is finalized by the implement stage. + +use base64::Engine; +use tonic::metadata::MetadataValue; +use tonic::service::{interceptor::InterceptedService, Interceptor}; +use tonic::transport::{Channel, Endpoint}; +use tonic::{Request, Status}; + +use crabidy_core::proto::crabidy::crabidy_service_client::CrabidyServiceClient; + +use crate::{Connection, RemoteCmd}; + +/// Attaches `authorization: Basic …` when a user is configured; an empty user +/// means an open server. The header value is a secret and never logged. +#[derive(Clone)] +pub struct AuthInterceptor { + header: Option>, +} + +impl AuthInterceptor { + fn new(user: &str, password: &str) -> Result> { + if user.is_empty() { + return Ok(Self { header: None }); + } + let encoded = + base64::engine::general_purpose::STANDARD.encode(format!("{user}:{password}")); + let header = format!("Basic {encoded}") + .parse() + .map_err(|_| "cannot encode credentials header")?; + Ok(Self { + header: Some(header), + }) + } +} + +impl Interceptor for AuthInterceptor { + fn call(&mut self, mut request: Request<()>) -> Result, Status> { + if let Some(header) = &self.header { + request + .metadata_mut() + .insert("authorization", header.clone()); + } + Ok(request) + } +} + +type Client = CrabidyServiceClient>; + +/// Connects (lazily) to the server described by `conn`. +async fn connect(conn: &Connection) -> Result> { + let endpoint = Endpoint::from_shared(conn.address.clone())?.connect_lazy(); + let interceptor = AuthInterceptor::new(&conn.user, &conn.password)?; + Ok(CrabidyServiceClient::with_interceptor( + endpoint, + interceptor, + )) +} + +/// Runs one remote command against the server and prints the result. +pub async fn run_remote( + conn: &Connection, + cmd: RemoteCmd, +) -> Result<(), Box> { + let _client = connect(conn).await?; + let _ = cmd; + todo!("dispatch each Library/Queue/Global variant to its RPC and print") +} diff --git a/cbd-cli/src/lib.rs b/cbd-cli/src/lib.rs new file mode 100644 index 0000000..bc7875d --- /dev/null +++ b/cbd-cli/src/lib.rs @@ -0,0 +1,306 @@ +//! Shared command-line definitions for the crabidy binaries +//! (`crabidy-server`, `cbd-tui`, `cbd`), plus a feature-gated gRPC executor +//! for the remote `library`/`queue`/`global` commands. +//! +//! See `architecture/cli.md`. The clap types live here so every binary shares +//! one command surface and each binary's `build.rs` can generate shell +//! completions and man pages from its own top-level [`clap::Command`] with the +//! default (clap-only) feature set. The `client` feature adds [`run_remote`], +//! which the binaries call to execute a remote command against a running +//! server. + +use clap::{Args, Parser, Subcommand, ValueEnum}; + +/// A server auth role. The `ValueEnum` names double as the basic-auth user +/// names the server expects. +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum Role { + /// Full control (the normal user). + Owner, + /// Anything on the queue and playback, no library writes. + QueueOwner, + /// May browse and append to the queue; nothing else. + Appender, +} + +impl Role { + /// The basic-auth user name for this role. + pub fn user_name(self) -> &'static str { + match self { + Role::Owner => "owner", + Role::QueueOwner => "queue-owner", + Role::Appender => "queue-appender", + } + } +} + +/// Connection flags shared by every remote command. Defaults match the client +/// config; a flag overrides the config-file value. +#[derive(Debug, Clone, Args)] +pub struct RemoteArgs { + /// Server address (default: the client config's, else localhost). + #[arg(short, long)] + pub address: Option, + /// Basic-auth user / role name (empty against an open server). + #[arg(short, long)] + pub user: Option, + /// Basic-auth password. + #[arg(short, long)] + pub password: Option, +} + +/// Library operations against a running server. +#[derive(Debug, Subcommand)] +pub enum LibraryCmd { + /// List a node's child nodes and tracks (default path `/`). + List { + #[arg(default_value = "/")] + path: String, + }, + /// Create a child node under a creatable parent (e.g. a search term). + Create { parent: String, title: String }, + /// Rename an editable node. + Rename { path: String, title: String }, + /// Delete a deletable node or track (never touches the content store). + Delete { path: String }, + /// Save a subtree as a link bookmark under `/crabidy/`. + Save { path: String, name: String }, + /// Capture a subtree under `/crabidy/` (downloads audio). + Capture { path: String, name: String }, +} + +/// Queue operations against a running server. +#[derive(Debug, Subcommand)] +pub enum QueueCmd { + /// Print the current queue. + Show, + /// Append tracks/subtrees (by library path) to the end of the queue. + Append { paths: Vec }, + /// Insert tracks/subtrees at a position. + Insert { position: u32, paths: Vec }, + /// Replace the whole queue with the given tracks/subtrees. + Replace { paths: Vec }, + /// Remove queue entries by position. + Remove { positions: Vec }, + /// Clear the queue (optionally keeping the current track). + Clear { + #[arg(long)] + keep_current: bool, + }, + /// Jump to a queue position. + SetCurrent { position: u32 }, + /// Link-save the current queue as `/crabidy/`. + Save { name: String }, + /// Capture the current queue into `/crabidy/` (downloads audio). + Capture { name: String }, + /// Toggle shuffle. + Shuffle, + /// Toggle repeat. + Repeat, +} + +/// Playback and global operations against a running server. +#[derive(Debug, Subcommand)] +pub enum GlobalCmd { + /// Toggle play/pause. + Play, + /// Stop playback. + Stop, + /// Skip to the next track. + Next, + /// Go to the previous track. + Prev, + /// Restart the current track. + Restart, + /// Toggle mute. + Mute, + /// Change the volume by a delta in [-1.0, 1.0] (e.g. `0.05`, `-0.1`). + Volume { delta: f32 }, +} + +/// The remote command groups an executor can run (`client` feature). +#[derive(Debug, Subcommand)] +pub enum RemoteCmd { + /// Library operations. + #[command(subcommand)] + Library(LibraryCmd), + /// Queue operations. + #[command(subcommand)] + Queue(QueueCmd), + /// Playback / global operations. + #[command(subcommand)] + Global(GlobalCmd), +} + +/// `guard [password]`: hash a role password and (unless `--no-config`) +/// write it into `crabidy-server.toml`'s `[auth]`. +#[derive(Debug, Args)] +pub struct GuardArgs { + /// Which role's password to set. + pub role: Role, + /// The password. If omitted, it is read from stdin (pipe-friendly, and + /// keeps it out of the process list). + pub password: Option, + /// Only print the PHC hash; do not modify `crabidy-server.toml`. + #[arg(long)] + pub no_config: bool, +} + +/// `scan [--capture|--move]`: index a music folder. +#[derive(Debug, Args)] +pub struct ScanArgs { + /// Directory to walk for playable files. + pub path: std::path::PathBuf, + /// Copy each file into the content store; the toml points there. + #[arg(long)] + pub capture: bool, + /// Move each file into the content store instead of copying. + #[arg(long, name = "move")] + pub move_: bool, +} + +/// `auth [password]`: write a role + cleartext password into the client +/// config. +#[derive(Debug, Args)] +pub struct AuthArgs { + /// Which role to authenticate as. + pub role: Role, + /// The cleartext password (read from stdin if omitted). + pub password: Option, + /// Also set the server address in the client config. + #[arg(short, long)] + pub address: Option, +} + +/// `completions `: print a completion script to stdout. +#[derive(Debug, Args)] +pub struct CompletionsArgs { + /// Target shell. + pub shell: clap_complete::Shell, +} + +/// Subcommands of `crabidy-server`. +#[derive(Debug, Subcommand)] +pub enum ServerCommand { + /// Set a role password (hash + write config). + Guard(GuardArgs), + /// Index a music folder with `.cbd-track.toml` files. + Scan(ScanArgs), + /// Library operations against a running server. + #[command(subcommand)] + Library(LibraryCmd), + /// Queue operations against a running server. + #[command(subcommand)] + Queue(QueueCmd), + /// Playback / global operations against a running server. + #[command(subcommand)] + Global(GlobalCmd), + /// Print a shell completion script. + Completions(CompletionsArgs), +} + +/// `crabidy-server`: no subcommand runs the server. +#[derive(Debug, Parser)] +#[command(name = "crabidy-server", author, version, about)] +pub struct ServerCli { + #[command(flatten)] + pub remote: RemoteArgs, + #[command(subcommand)] + pub command: Option, +} + +/// Subcommands of `cbd-tui`. +#[derive(Debug, Subcommand)] +pub enum TuiCommand { + /// Write a role + password into the client config. + Auth(AuthArgs), + #[command(subcommand)] + Library(LibraryCmd), + #[command(subcommand)] + Queue(QueueCmd), + #[command(subcommand)] + Global(GlobalCmd), + /// Print a shell completion script. + Completions(CompletionsArgs), +} + +/// `cbd-tui`: no subcommand runs the TUI. +#[derive(Debug, Parser)] +#[command(name = "cbd-tui", author, version, about)] +pub struct TuiCli { + #[command(flatten)] + pub remote: RemoteArgs, + /// Show the frequency-spectrum bars under the track progress. + #[arg(long)] + pub spectrum: Option, + #[command(subcommand)] + pub command: Option, +} + +/// Subcommands of `cbd` — the union of the server and client commands. +#[derive(Debug, Subcommand)] +pub enum CbdCommand { + Guard(GuardArgs), + Scan(ScanArgs), + Auth(AuthArgs), + #[command(subcommand)] + Library(LibraryCmd), + #[command(subcommand)] + Queue(QueueCmd), + #[command(subcommand)] + Global(GlobalCmd), + /// Print a shell completion script. + Completions(CompletionsArgs), +} + +/// `cbd`: no subcommand runs the in-process server + TUI. +#[derive(Debug, Parser)] +#[command(name = "cbd", author, version, about)] +pub struct CbdCli { + #[command(flatten)] + pub remote: RemoteArgs, + #[arg(long)] + pub spectrum: Option, + #[command(subcommand)] + pub command: Option, +} + +/// Resolved connection settings for the executor (owned, unlike the TUI's +/// `&'static ServerConfig`). +#[derive(Debug, Clone)] +pub struct Connection { + pub address: String, + pub user: String, + pub password: String, +} + +/// Writes bash/zsh/fish completions and a man page for `cmd` into `dir`. +/// Used by each binary's `build.rs` (architecture/cli.md D7). +pub fn generate_assets( + mut cmd: clap::Command, + bin_name: &str, + dir: &std::path::Path, +) -> std::io::Result<()> { + use clap_complete::Shell; + std::fs::create_dir_all(dir.join("completions"))?; + std::fs::create_dir_all(dir.join("man"))?; + for shell in [Shell::Bash, Shell::Zsh, Shell::Fish] { + clap_complete::generate_to(shell, &mut cmd, bin_name, dir.join("completions"))?; + } + let man = clap_mangen::Man::new(cmd); + let mut out = Vec::new(); + man.render(&mut out)?; + std::fs::write(dir.join("man").join(format!("{bin_name}.1")), out)?; + Ok(()) +} + +/// Prints a completion script for `shell` to stdout (the `completions` +/// subcommand). +pub fn print_completions(shell: clap_complete::Shell, cmd: &mut clap::Command, bin_name: &str) { + clap_complete::generate(shell, cmd, bin_name, &mut std::io::stdout()); +} + +#[cfg(feature = "client")] +mod client; +#[cfg(feature = "client")] +pub use client::run_remote;