Design a comprehensive clap-derive CLI (architecture/cli.md)

Stage 1 of the CLI dev-flow: every binary becomes a clap-derive CLI with
--help; no subcommand keeps the current default (TUI / run server / both).
A shared cbd-cli crate holds the clap definitions and a feature-gated gRPC
executor for the remote library/queue/global commands; server guard/scan
and client auth live in their binaries; completions + man pages generate in
each build.rs. Includes a d2 component diagram.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Test User 2026-07-22 11:45:05 +02:00
parent 99419dcdcf
commit 56098f7c26
1 changed files with 238 additions and 0 deletions

238
architecture/cli.md Normal file
View File

@ -0,0 +1,238 @@
# A comprehensive CLI for every binary
## Context and problem statement
Today the command line is thin and inconsistent:
- `crabidy-server` uses clap-derive but exposes only `hash-password` and no
flags (it always binds the fixed `LISTEN_ADDR`).
- `cbd-tui` and `cbd` share a ClapSerde `Config` (`-a/-u/-p`, `--spectrum`)
that doubles as the TOML schema; neither has subcommands.
- Every user operation is reachable *only* through the interactive TUI. There
is no way to script the player, set up auth, or index a music folder from the
shell.
The goal: **every binary is a clap-derive CLI with `--help`; every operation
the TUI can do is also a subcommand; there are shell completions and man pages;
and running a binary with no subcommand behaves exactly as today** (TUI, run the
server, or `cbd`'s server+TUI).
New capabilities requested:
- **`guard`** (server): hash a role password and, by default, write it into
`crabidy-server.toml`'s `[auth]`.
- **`scan`** (server): walk a path, drop a `.cbd-track.toml` beside every
playable file; `--capture` copies the audio into the content store (toml
points there), `--move` moves it instead of copying.
- **`auth`** (client): write a role + cleartext password into the client config.
- **`library` / `queue` / `global`** (server *and* client): the full set of
library, queue, and playback operations, run against a server over gRPC.
## Assumptions
- The `library`/`queue`/`global` commands are **remote**: they connect to a
running server at `--address` (default localhost) with the same basic-auth as
the TUI, reusing the generated gRPC client. A "server" binary running them is
just acting as a client to whatever server is up — including its own.
- One config file per client binary stays the source of truth for connection
defaults (`cbd-tui.toml`, `cbd.toml`); flags override it.
- Passwords may be given as an argument *or* on stdin. Argv is visible in the
process list — the help text says so, and omitting the argument reads stdin
(pipe-friendly, the current `hash-password` behavior).
- No tag extraction in `scan` v1 (title = file stem); no machine-readable
output format v1 (human-readable text). Both are noted as future work.
## D1 — A shared `cbd-cli` crate
A new library crate **`cbd-cli`** holds the clap definitions so all three
binaries share one command surface and each binary's `build.rs` can generate
assets from it. It is feature-split to keep `build.rs` light:
- **default features (clap only)**: the `Parser`/`Subcommand` types
(`LibraryCmd`, `QueueCmd`, `GlobalCmd`, `GuardCmd`, `ScanArgs`, `AuthCmd`, the
per-binary top-level `ServerCli`/`TuiCli`/`CbdCli`), a `Role` enum, a
`RemoteArgs` flatten (`--address/--user/--password`), and
`generate_assets(cmd, out_dir)` (wrapping `clap_complete` + `clap_mangen`).
Depends only on `clap`, `clap_complete`, `clap_mangen`.
- **feature `client`**: `run_remote(remote: &RemoteArgs, cmd: RemoteCmd)` — the
executor that connects a `crabidy_core` gRPC client (with a standalone
basic-auth interceptor) and runs a `library`/`queue`/`global` subcommand,
printing results. Adds `tonic`, `crabidy-core`, `tokio`.
`guard`/`scan`/`auth` *definitions* live in `cbd-cli` (so completions and man
pages cover them) but their *execution* lives in the owning binary, which has
the server config / store / client config internals `cbd-cli` must not depend
on.
```d2
direction: right
cbd_cli: cbd-cli (clap defs + asset gen) {
defs: "Parser/Subcommand types\nRemoteArgs, Role\ngenerate_assets()"
client: "feature=client:\nrun_remote() gRPC executor"
}
server: crabidy-server {
guard_scan: "guard + scan\n(config writer, store)"
}
tui: cbd-tui {
auth: "auth (client config writer)"
}
cbd: cbd (bundle)
core: crabidy-core (generated client)
cbd_cli.defs -> server.guard_scan: defines
cbd_cli.defs -> tui.auth: defines
cbd_cli.client -> core: gRPC to a running server
server -> cbd_cli.client: library/queue/global
tui -> cbd_cli.client: library/queue/global
cbd -> server: reuses guard/scan
cbd -> tui: reuses auth
cbd -> cbd_cli.client: library/queue/global
```
## D2 — Per-binary CLI and the no-subcommand default
Each binary defines a top-level clap `Parser` (in `cbd-cli`) with global
connection flags and an **optional** subcommand:
- `ServerCli`: `[guard|scan|library|queue|global|completions]`; none → run the
server (as today).
- `TuiCli`: `RemoteArgs` + `--spectrum` + `[auth|library|queue|global|
completions]`; none → run the TUI.
- `CbdCli`: the union — `[guard|scan|auth|library|queue|global|completions]`;
none → server + TUI (as today). This is "cbd has the commands from both."
**Config merge.** The no-subcommand path must keep today's behavior: read the
TOML (writing defaults on first run) and let flags override. ClapSerde did this
implicitly; a top-level `Parser` with a subcommand does not compose cleanly with
ClapSerde's `Opt`. Decision: replace ClapSerde for the client binaries with a
plain `serde` config load plus an explicit override step — the `RemoteArgs`
fields are `Option`, and a provided flag overrides the file value. `init_config`
keeps writing a defaults file on first run. This is a small, well-contained
change to `cbd-tui/src/config.rs` and the two `main.rs` files.
Alternative considered: sniff argv for a known subcommand before ClapSerde
parsing and branch. Rejected — it forfeits a unified `--help`/completions and is
fragile.
## D3 — `library` / `queue` / `global` (remote)
The executor (`cbd-cli` feature `client`) maps subcommands to the existing RPCs
(`RpcClient` already wraps all but `Stop`, which gains a wrapper):
- `library list [PATH]` (default `/`) → `GetLibraryNode`; prints child nodes and
tracks (path, title; captured rows marked, per architecture/crabidy-store.md).
- `library create <PARENT> <TITLE>`, `rename <PATH> <TITLE>`,
`delete <PATH>` → the matching library RPCs.
- `library save <PATH> <NAME>``CaptureLibraryNode{download:false}`;
`library capture <PATH> <NAME>``{download:true}`.
- `queue show``Queue`; `queue append|insert|replace <PATH>…`,
`queue remove <POS>…`, `queue clear [--keep-current]`,
`queue set-current <POS>`, `queue save <NAME>`, `queue capture <NAME>`
(→ `CaptureLibraryNode` on `/crabidy/current`), `queue shuffle`,
`queue repeat` (the `QueueModifiers` toggles).
- `global play|stop|next|prev|restart|mute`, `global volume <DELTA>` (or
`up|down` sugar around `ChangeVolume`).
Connection: `RemoteArgs` → a lazily-connected `CrabidyServiceClient` with a
basic-auth interceptor built from `--user/--password` (empty user = no header,
i.e. an open server). Each command is a one-shot: connect, call, print, exit.
`RpcClient::connect`'s `&'static ServerConfig` signature is loosened (or the
executor builds the client directly) so a CLI can pass an owned config.
Output is human-readable text; a `--json` flag is future work (D9).
## D4 — `guard` (server)
`crabidy-server guard <ROLE> [PASSWORD] [--no-config]` where `ROLE`
`owner|queue-owner|appender`:
1. Read the password from the argument, or from stdin if omitted.
2. Hash it (`auth::hash_password`, argon2id) and **print the PHC string** to
stdout (so it stays pipeable and `--no-config` reproduces today's
`hash-password`).
3. Unless `--no-config`: load `crabidy-server.toml` (or defaults), set the role's
field (`owner`/`queue_owner`/`queue_appender`) to the hash, and write it back,
preserving the flat `[auth]` shape and the other roles. This needs a **config
writer** (D8) — none exists today.
`hash-password` is removed in favor of `guard` (`guard owner <pw> --no-config`
is the exact replacement). The role names match the basic-auth user names.
## D5 — `scan` (server)
`crabidy-server scan <PATH> [--capture] [--move]`:
- Walk `<PATH>` recursively (bounded, skipping hidden entries), selecting files
by audio extension (flac/mp3/m4a/ogg/opus/wav/webm/aac…).
- For each audio file, write a `<stem>.cbd-track.toml` **beside it** using
`fsdy::TrackFile` (`to_toml`, the shared naming), so the folder becomes a
browsable `/fs` tree. Title defaults to the file stem (tag extraction is
future work).
- Default (neither flag): the toml's playable is `Playable::File` pointing at
the audio's own file name (relative) — the audio stays where it is.
- `--capture`: ingest the audio into the content store (hash + de-dup + sidecar,
reusing `CrabidyStore`), and the beside-file toml gets a `Playable::Store`
entry instead. Idempotent: an already-stored file de-dups.
- `--move`: like `--capture` but the source audio is moved into the store, not
copied — the original location keeps only the toml.
- `--capture`/`--move` require the content store (a state/data dir);
`scan` opens a `CrabidyStore` directly and calls a new `ingest_file(path,
move) -> StoreName` helper (the local-source half of the D4 capture flow,
factored out).
Existing `.cbd-track.toml` files are left untouched (scan never clobbers a
hand-edited toml); a warning notes skips.
## D6 — `auth` (client)
`cbd-tui auth <ROLE> [PASSWORD] [--address ADDR]` (and the same on `cbd`):
loads the client config (`cbd-tui.toml` / `cbd.toml`), sets `user` to the role
name and `password` to the cleartext (optionally `address`), and writes it back
via the **client config writer** (D8). The file is chmod-private-friendly; the
help text repeats that the client config holds a plaintext password.
## D7 — Shell completions and man pages
Add `clap_complete` and `clap_mangen`. Each binary's `build.rs` build-depends on
`cbd-cli` (default features — clap only, cheap) and, from its top-level
`Command`, writes bash/zsh/fish completions and a `man` page into `OUT_DIR`
every build (a genuine build step). When `CBD_ASSET_DIR` is set, `build.rs` also
copies them into that stable directory. A devenv `gen-cli-assets` script sets
`CBD_ASSET_DIR=$PWD/dist` and builds, so `dist/completions/**` and `dist/man/*.1`
are produced on demand. A hidden `completions <shell>` subcommand on each binary
prints a completion script to stdout for ad-hoc use.
Alternative considered: an `xtask` generator binary. Rejected — a `build.rs`
keeps generation automatic and in lockstep with the CLI definition; the
`CBD_ASSET_DIR` copy covers the "get them into the repo" need.
## D8 — Config writers (new)
Two small, careful serializers, both load-modify-write preserving shape:
- **Server** (`crabidy-server`): `ServerSettings` gains a `store(config_dir)`
that serializes the current `[auth]` (round-tripping the existing file so
unknown-field rejection stays satisfiable) — used by `guard`.
- **Client** (`cbd-tui`): the `Config`/`ServerConfig` is already `Serialize`;
`auth` loads it, sets the fields, and writes `toml::to_string_pretty` back to
the config path — used by `auth`.
Both write to `dirs::config_dir()/crabidy/<file>` and create it if missing.
## D9 — Out of scope / future
- Tag extraction in `scan` (a `lofty`-backed title/artist/album/duration).
- `--json` machine-readable output for `library list` / `queue show`.
- Watching/streaming (`global watch` over `GetUpdateStream`).
- Bulk auth (multiple roles in one `guard` invocation).
## Risks
- **Password in argv** is visible process-wide; mitigated by the stdin fallback
and documented in help. Accepted per the explicit request.
- **ClapSerde → clap migration** for the client config changes the parse path;
the first-run-writes-defaults and flag-overrides-file behaviors must be
preserved by tests.
- **`build.rs` writing outside `OUT_DIR`** (the `CBD_ASSET_DIR` copy) is
unconventional; gated on the env var so ordinary builds only touch `OUT_DIR`.
- **`cbd-cli` as a build-dependency** must stay clap-only by default, or every
build drags in tonic — enforced by the feature split.