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) <noreply@anthropic.com>
This commit is contained in:
Test User 2026-07-22 11:48:30 +02:00
parent 56098f7c26
commit e215dd8b87
5 changed files with 448 additions and 0 deletions

38
Cargo.lock generated
View File

@ -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"

View File

@ -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" }

28
cbd-cli/Cargo.toml Normal file
View File

@ -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"]

72
cbd-cli/src/client.rs Normal file
View File

@ -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<MetadataValue<tonic::metadata::Ascii>>,
}
impl AuthInterceptor {
fn new(user: &str, password: &str) -> Result<Self, Box<dyn std::error::Error>> {
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<Request<()>, Status> {
if let Some(header) = &self.header {
request
.metadata_mut()
.insert("authorization", header.clone());
}
Ok(request)
}
}
type Client = CrabidyServiceClient<InterceptedService<Channel, AuthInterceptor>>;
/// Connects (lazily) to the server described by `conn`.
async fn connect(conn: &Connection) -> Result<Client, Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
let _client = connect(conn).await?;
let _ = cmd;
todo!("dispatch each Library/Queue/Global variant to its RPC and print")
}

306
cbd-cli/src/lib.rs Normal file
View File

@ -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<String>,
/// Basic-auth user / role name (empty against an open server).
#[arg(short, long)]
pub user: Option<String>,
/// Basic-auth password.
#[arg(short, long)]
pub password: Option<String>,
}
/// 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/<name>`.
Save { path: String, name: String },
/// Capture a subtree under `/crabidy/<name>` (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<String> },
/// Insert tracks/subtrees at a position.
Insert { position: u32, paths: Vec<String> },
/// Replace the whole queue with the given tracks/subtrees.
Replace { paths: Vec<String> },
/// Remove queue entries by position.
Remove { positions: Vec<u32> },
/// 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/<name>`.
Save { name: String },
/// Capture the current queue into `/crabidy/<name>` (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 <role> [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<String>,
/// Only print the PHC hash; do not modify `crabidy-server.toml`.
#[arg(long)]
pub no_config: bool,
}
/// `scan <path> [--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 <role> [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<String>,
/// Also set the server address in the client config.
#[arg(short, long)]
pub address: Option<String>,
}
/// `completions <shell>`: 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<ServerCommand>,
}
/// 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<bool>,
#[command(subcommand)]
pub command: Option<TuiCommand>,
}
/// 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<bool>,
#[command(subcommand)]
pub command: Option<CbdCommand>,
}
/// 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;