# Phase 23: CLI Tool

## Goal

Provide a command-line tool (`fafb-q` or `fafbctl`) that developers and operators use to interact with a running fafb-q server — publish messages, subscribe and tail topics, inspect queue state, manage namespaces, and diagnose problems. The CLI is the primary interface for debugging, operations, and quick integration testing. Without it, every interaction requires writing code or crafting HTTP requests by hand, which slows adoption and makes the system feel inaccessible.

## Guiding Principles

- **Single binary, two modes.** The `fafb-q` binary serves dual purpose: `fafb-q serve` runs the server, `fafb-q <command>` runs CLI operations. One binary to distribute, one binary to learn. The server is the default if no subcommand is given (or `serve` is explicit). **Note:** The current `main.rs` has `CliArgs { config: Option<PathBuf> }` — adding subcommands is a breaking change to the CLI interface. `fafb-q --config foo.toml` must become `fafb-q serve --config foo.toml`. Backward compatibility: if no subcommand is given, default to `serve` (preserves the current behavior for existing scripts/systemd units).
- **TCP-first, HTTP-fallback.** The CLI connects via TCP by default (full protocol, streaming, efficient). For environments where only HTTP is available (firewalls, proxies), `--http` flag switches to HTTP mode with gracefully degraded functionality.
- **Unix philosophy.** Output is human-readable by default, machine-parseable with `--json`. Pipe-friendly: `fafb-q fetch myns/orders --from 0 --count 100 | jq '.payload'`. Exit codes are meaningful (0 = success, 1 = error, 2 = timeout).
- **Minimal configuration.** Connection details come from `--server` flag, `FAFB_SERVER` env var, or `~/.fafb-q/config.toml`. API key from `--api-key`, `FAFB_API_KEY`, or config file. One flag to connect, one flag to auth.
- **Interactive subscribe.** `fafb-q sub myns/orders` opens a streaming subscription and prints messages as they arrive — like `tail -f` for a queue. Ctrl+C unsubscribes cleanly. This is the "wow" demo experience.

## Design

### Command Structure

```
fafb-q [global-flags] <command> [command-flags] [args]

Global flags:
  --server, -s <addr>      Server address (default: localhost:9876)
  --http                   Use HTTP instead of TCP
  --api-key, -k <key>      API key for authentication
  --namespace, -n <ns>     Default namespace (can also be topic prefix: ns/topic)
  --json                   JSON output for all commands
  --verbose, -v            Verbose output (show protocol frames)
  --timeout <secs>         Command timeout (default: 30)
  --tls                    Enable TLS
  --tls-ca <path>          CA certificate for TLS verification
  --tls-cert <path>        Client certificate for mTLS
  --tls-key <path>         Client key for mTLS
```

### Commands

#### `serve` — Run the Server

```bash
fafb-q serve [--config <path>]
```

Runs the server (existing functionality, moved under a subcommand). If no subcommand is given, `serve` is implied for backward compatibility.

#### `pub` — Publish Messages

```bash
# Publish a string message
fafb-q pub myns/orders "hello world"

# Publish from stdin
echo '{"order_id": 42}' | fafb-q pub myns/orders

# Publish from file
fafb-q pub myns/orders --file order.json

# Publish with TTL (Plan 22)
fafb-q pub myns/orders --ttl 30s "time-sensitive data"

# Publish with headers (Plan 19)
fafb-q pub myns/orders -H "Content-Type: application/json" -H "Trace-Id: abc123" '{"id": 1}'

# Publish multiple messages (one per line from stdin)
cat messages.jsonl | fafb-q pub myns/orders --lines

# Fire-and-forget (no ack wait)
fafb-q pub myns/orders --no-ack "fire and forget"
```

**Output:**
```
published offset=42 topic=orders timestamp=2026-03-23T10:15:30Z
```

JSON mode:
```json
{"offset": 42, "topic": "orders", "timestamp_millis": 1711185330000}
```

#### `sub` — Subscribe and Stream

```bash
# Subscribe from head (new messages only)
fafb-q sub myns/orders

# Subscribe from a specific offset
fafb-q sub myns/orders --from 100

# Subscribe from a timestamp
fafb-q sub myns/orders --since "2026-03-23T10:00:00Z"

# Subscribe with consumer group
fafb-q sub myns/orders --group my-cli-group

# Subscribe to wildcard (Plan 20)
fafb-q sub "myns/orders.*"

# Limit number of messages then exit
fafb-q sub myns/orders --count 10

# Auto-ack (default for CLI) vs manual ack
fafb-q sub myns/orders --no-ack    # receive only, don't ack
```

**Output (default):**
```
[orders] offset=42 ts=2026-03-23T10:15:30Z
hello world

[orders] offset=43 ts=2026-03-23T10:15:31Z
{"order_id": 42}
```

JSON mode (one JSON object per line — JSONL):
```json
{"topic":"orders","offset":42,"timestamp_millis":1711185330000,"payload":"hello world"}
{"topic":"orders","offset":43,"timestamp_millis":1711185331000,"payload":"{\"order_id\": 42}"}
```

**Behavior:** Runs until Ctrl+C (or `--count` reached). On SIGINT, sends `Unsubscribe`, waits for `UnsubscribeAck`, then exits cleanly.

#### `fetch` — Pull Messages

```bash
# Fetch from offset
fafb-q fetch myns/orders --from 0 --count 100

# Fetch from timestamp
fafb-q fetch myns/orders --since "2026-03-23T10:00:00Z" --count 50

# Fetch all (paginate automatically)
fafb-q fetch myns/orders --from 0 --all

# Output just payloads (for piping)
fafb-q fetch myns/orders --from 0 --count 10 --payload-only

# Fetch last message (negative offset — Plan 15 Phase E)
fafb-q fetch myns/orders --from -1 --count 1

# Fetch last N messages (shorthand — Plan 15 Phase E)
fafb-q fetch myns/orders --tail 10
```

Uses TCP Fetch protocol by default, HTTP fetch with `--http`.

#### `info` — Queue Information

```bash
# Topic info (uses GET /{namespace}/{topic}/info)
fafb-q info myns/orders

# All topics in namespace (uses GET /{namespace}/topics)
fafb-q info myns

# All namespaces (requires admin key — new endpoint needed)
fafb-q info --admin
```

**Existing HTTP endpoints used:**
- `GET /{namespace}/{topic}/info` — topic info (already exists)
- `GET /{namespace}/topics` — list topics in namespace (already exists)
- `GET /admin/namespaces` — list all namespaces (**NEW — needs to be added**)

**Output:**
```
Namespace: myapp
Topic: orders
  Head offset:    1,234,567
  Base offset:    1,200,000
  Messages:       34,567
  Disk bytes:     128 MB (12 segments)
  Subscribers:    3 (2 groups)
  Consumer groups:
    web-api:      offset=1,234,500 lag=67
    analytics:    offset=1,230,000 lag=4,567
```

> **Note:** Consumer group lag and subscriber count require data from the namespace manager, not just the queue engine. The existing `topic_info` endpoint may need to be extended to include subscriber/group information. Currently, it likely returns offset and depth info but not subscription details.

#### `drain` — Trigger Graceful Drain

```bash
# Drain with defaults
fafb-q drain --admin-key <key>

# Drain with custom timeout
fafb-q drain --admin-key <key> --timeout 60

# Check drain status
fafb-q drain --admin-key <key> --status
```

Wraps the `POST /admin/drain` and `GET /admin/drain/status` HTTP endpoints.

#### `health` — Health Check

```bash
fafb-q health                    # readiness check
fafb-q health --live             # liveness check
```

Exit code: 0 = healthy, 1 = unhealthy. Useful in scripts and cron jobs.

#### `config` — Configuration Utilities

```bash
# Validate a config file
fafb-q config validate --file fafb-q.toml

# Dump resolved config (connects to server)
fafb-q config dump

# Show example config
fafb-q config example
```

#### `bench` — Simple Benchmark

```bash
# Publish benchmark
fafb-q bench pub myns/bench --messages 100000 --payload-size 256 --concurrency 4

# Subscribe benchmark (consume as fast as possible)
fafb-q bench sub myns/bench --group bench-consumer

# End-to-end latency benchmark
fafb-q bench latency myns/bench --messages 1000
```

**Output:**
```
Published 100,000 messages in 2.34s
  Throughput: 42,735 msg/s (10.4 MB/s)
  p50 latency: 0.12ms
  p99 latency: 0.89ms
  p999 latency: 2.1ms
```

### CLI Config File

`~/.fafb-q/config.toml`:

```toml
# Default connection
[default]
server = "localhost:9876"
api_key = "sk_dev_abc123"
namespace = "myapp"
tls = false

# Named profiles
[profiles.production]
server = "fafb-q.prod.internal:9876"
api_key_env = "FAFB_PROD_KEY"   # read from env var
namespace = "prod"
tls = true
tls_ca = "/etc/ssl/ca.pem"

[profiles.staging]
server = "fafb-q.staging.internal:9876"
api_key = "sk_staging_xyz"
namespace = "staging"
```

Usage: `fafb-q --profile production sub myns/orders`

### Implementation Architecture

The CLI is built into the same binary as the server. No separate crate or binary.

**Current structure:** `main.rs` is ~800 lines containing the entire server startup sequence (tracing init, storage/engine/manager creation, HTTP/TCP server launch, background tasks, signal handling, shutdown). The `CliArgs` struct has only `config: Option<PathBuf>`.

**Restructuring approach:**

```rust
// src/main.rs — restructured
use clap::{Parser, Subcommand};

#[derive(Parser)]
#[command(name = "fafb-q")]
struct Cli {
    #[command(subcommand)]
    command: Option<Command>,  // None = serve (backward compat)
}

#[derive(Subcommand)]
enum Command {
    /// Run the server (default if no subcommand given)
    Serve(ServeArgs),
    /// Publish messages
    Pub(PubArgs),
    /// Subscribe to topics
    Sub(SubArgs),
    /// Fetch messages
    Fetch(FetchArgs),
    /// Queue information
    Info(InfoArgs),
    /// Trigger graceful drain
    Drain(DrainArgs),
    /// Health check
    Health(HealthArgs),
    /// Configuration utilities
    Config(ConfigArgs),
    /// Benchmarking
    Bench(BenchArgs),
}

#[tokio::main]
async fn main() {
    let cli = Cli::parse();
    match cli.command.unwrap_or(Command::Serve(ServeArgs::default())) {
        Command::Serve(args) => run_server(args).await,
        Command::Pub(args) => run_publish(args).await,
        // ...
    }
}
```

**Key refactoring:** Extract the current `main()` body into `run_server(args: ServeArgs)`. This is a large refactor (~800 lines of server startup code) but is mechanical — no behavioral changes. The server code remains in `main.rs` or moves to a new `src/serve.rs`. CLI command handlers go in `src/cli/` module.

**Module structure:**
```
src/
  cli/
    mod.rs         — shared client, output formatting
    publish.rs     — pub command
    subscribe.rs   — sub command
    fetch.rs       — fetch command
    info.rs        — info command
    admin.rs       — drain, health, config commands
    bench.rs       — benchmark commands
  client.rs        — FafbClient (reusable TCP/HTTP client)
```

### Client Connection Module

The CLI commands need a reusable TCP client connection:

```rust
// src/client.rs (new module)

pub struct FafbClient {
    stream: FramedStream,       // length-delimited codec
    capabilities: Capabilities,
    namespace: NamespaceId,
}

impl FafbClient {
    /// Connect, authenticate, negotiate capabilities.
    pub async fn connect(config: &ClientConfig) -> Result<Self, Error>;

    /// Publish a message, wait for PublishAck.
    pub async fn publish(&mut self, topic: &str, payload: Bytes, opts: PublishOpts) -> Result<PublishAck, Error>;

    /// Subscribe and return a message stream.
    pub async fn subscribe(&mut self, topic: &str, opts: SubscribeOpts) -> Result<MessageStream, Error>;

    /// Fetch messages by offset/timestamp.
    pub async fn fetch(&mut self, topic: &str, opts: FetchOpts) -> Result<FetchResponse, Error>;

    /// Send unsubscribe and wait for ack.
    pub async fn unsubscribe(&mut self, topic: &str) -> Result<(), Error>;

    /// Clean disconnect.
    pub async fn close(self) -> Result<(), Error>;
}
```

This client module is the foundation for a future Rust client SDK crate.

### Output Formatting

```rust
enum OutputFormat {
    Human,  // colored, aligned, readable (default for TTY)
    Json,   // one JSON object per line (default for pipe)
    Raw,    // payload bytes only (--payload-only)
}

// Auto-detect: if stdout is a TTY, use Human; if piped, use Json
// Override with --json or --payload-only
```

## Implementation Phases

### Phase 23.1 — Binary Restructuring & Client Module

**Goal:** Restructure `main.rs` into clap subcommands, build the reusable `FafbClient` TCP connection module, and add CLI config file loading. All existing server tests must pass — this is a structural refactor with zero behavioral changes.

- [x] Restructure `main.rs` with clap subcommands
  - [x] Define `Cli` struct with `Option<Command>` — `None` defaults to `Serve` for backward compatibility
  - [x] Define `Command` enum: `Serve`, `Pub`, `Sub`, `Fetch`, `Info`, `Drain`, `Health`, `Config`, `Bench`
  - [x] Extract current `main()` body into `run_server(args: ServeArgs)` function
  - [x] `ServeArgs` wraps the existing `CliArgs` fields (`--config`, `--dump-config`, etc.)
  - [x] Stub handlers for all other commands (print "not yet implemented" and exit)
  - [x] Verify backward compat: `fafb-q --config foo.toml` still works (matches default `Serve`)
- [x] Add global flags to `Cli` struct
  - [x] `--server, -s <addr>` (default: `localhost:9876`, env: `FAFB_SERVER`)
  - [x] `--api-key, -k <key>` (env: `FAFB_API_KEY`)
  - [x] `--namespace, -n <ns>` (default namespace)
  - [x] `--json` (JSON output mode)
  - [x] `--verbose, -v` (show protocol frames)
  - [x] `--timeout <secs>` (default: 30)
  - [x] `--http` (use HTTP instead of TCP)
  - [x] TLS flags: `--tls`, `--tls-ca`, `--tls-cert`, `--tls-key`
- [x] Create `src/cli/mod.rs` module with shared utilities
  - [x] `OutputFormat` enum: `Human`, `Json`, `Raw`
  - [x] Topic/namespace parsing helper: `"myns/orders"` → `(namespace, topic)`
- [x] Implement `FafbClient` in `src/client.rs`
  - [x] `connect(config: &ClientConfig) -> Result<Self>` — TCP connect, TLS wrap (optional), frame `Connect`, receive `ConnectAck`
  - [x] `close(self) -> Result<()>` — clean disconnect
  - [x] Reuses existing `MessageCodec` and `Frame` types from `src/codec/`
- [x] CLI config file loading (`~/.fafb-q/config.toml`)
  - [x] Parse default connection settings: `server`, `api_key`, `namespace`, `tls`
  - [x] Config file is optional — missing file is not an error
  - [x] CLI flags override config file values
- [x] Run full existing test suite — all server tests must pass unchanged
- [x] Unit tests: argument parsing, topic/namespace parsing, config file loading

**Verification:** `fafb-q serve` runs the server identically to before. `fafb-q --config foo.toml` backward compat works. `FafbClient` can connect and auth to a test server. `cargo test` passes.

**Implementation notes (completed):**

- `CliArgs` renamed to `ServeArgs` across all source and test files. `ServeArgs` derives `Default` and has `#[command(about = "Run the fafb-q server")]`.
- Backward compat achieved via `#[command(args_conflicts_with_subcommands = true)]` with `#[command(flatten)]` on `ServeArgs` — `fafb-q --config foo.toml` works without explicit `serve` subcommand. Serve-specific flags (`--config`, `--tcp-bind`, etc.) correctly rejected on other subcommands.
- `GlobalOpts` struct derives `clap::Args` with `#[arg(global = true)]` on each field — all global flags (`--server`, `--api-key`, `--json`, `--verbose`, `--tls*`, `--profile`, `--timeout`, `--http`) are properly parsed by clap on all subcommands. `resolve_cli_config(&GlobalOpts) -> CliConfig` layers config file < env vars < CLI flags.
- Typed command arg structs: `PubArgs`, `SubArgs`, `FetchArgs`, `InfoArgs`, `DrainArgs`, `HealthArgs`, `ConfigAction` enum, `BenchAction` enum. Dispatch in `main()` passes parsed values directly.
- `CliConfigFile` includes `profiles: HashMap<String, CliProfile>` and `CliProfile::overlay()` for named profile merging — designed for Phase 23.6 without future struct shape changes. `--profile` flag wired in `GlobalOpts`.
- `FafbClient` uses `ClientStream` enum (`Plain(TcpStream)` / `Tls(Box<TlsStream<TcpStream>>)`) implementing `AsyncRead + AsyncWrite`. Full TLS support: `build_client_tls_config()` with CA cert loading, optional mTLS client cert/key, SNI derivation from server address or explicit `server_name`.
- `Cargo.toml`: added `env` feature to clap for `FAFB_SERVER`/`FAFB_API_KEY` env var support.
- 2 integration tests: `fafb_client_connect_and_capabilities`, `fafb_client_auth_failure` in `tests/tcp_integration.rs`.
- 16 unit tests across `cli/mod.rs` and `client.rs` (topic parsing, output format, namespace resolution, profile deserialization/overlay, config resolution, SNI derivation, client config construction, stream Unpin assertion).
- All tests pass at time of completion, zero clippy warnings, clean formatting.

---

### Phase 23.2 — Publish and Fetch Commands

**Goal:** Implement `fafb-q pub` and `fafb-q fetch` — the two core data commands. Both work over TCP via `FafbClient`.

- [x] Implement `fafb-q pub` command (`src/cli/publish.rs`)
  - [x] String argument mode: `fafb-q pub myns/orders "hello world"`
  - [x] Stdin mode: `echo "data" | fafb-q pub myns/orders` (detect no positional arg + stdin not TTY)
  - [x] File mode: `fafb-q pub myns/orders --file order.json`
  - [x] Multi-line mode: `--lines` flag reads one message per line from stdin
  - [x] TTL flag: `--ttl 30` (parsed as seconds, converted to millis) — requires `CAP_MESSAGE_TTL`
  - [x] Header flags: `-H "Key: Value"` (repeatable) — requires `CAP_HEADERS`
  - [x] `--no-ack` flag: don't wait for PublishAck
  - [x] Output: human-readable by default (`published message_id=42 topic=orders`), JSON with `--json`
- [x] Implement `fafb-q fetch` command (`src/cli/fetch.rs`)
  - [x] Offset mode: `--from <offset>` + `--count <n>` (default 100)
  - [x] Timestamp mode: `--since "2026-03-23T10:00:00Z"` + `--count <n>`
  - [x] Auto-pagination: `--all` flag fetches all messages from offset, paging automatically
  - [x] `--payload-only` flag: output only payload bytes (for piping)
  - [x] Uses `FafbClient::fetch()` (TCP Fetch frames)
  - [x] Falls back to HTTP fetch with `--http` flag
- [x] Implement output formatting (`src/cli/mod.rs`)
  - [x] Human format: readable, with metadata prefix per message
  - [x] JSON format: one JSON object per line (JSONL)
  - [x] Raw format: payload bytes only (`--payload-only`)
- [x] Integration test: start test server → `pub` via FafbClient → `fetch` via FafbClient → verify messages match
- [x] Integration test: publish with headers roundtrip
- [x] Integration test: publish with TTL roundtrip
- [x] Integration test: fetch with pagination (3 pages)
- [x] Integration test: publish_no_ack mode
- [x] Unit tests: header parsing, ISO8601 timestamp parsing
- [x] `FafbClient` gains `publish()`, `publish_no_ack()`, `fetch()`, `fetch_from()` high-level methods
- [x] All 982 tests pass, zero clippy warnings, clean formatting

**Verification:** `fafb-q pub` publishes messages via TCP. `fafb-q fetch` retrieves them. All input modes (string, stdin, file, lines) work. Output formats are correct.

**Implementation notes (completed):**

- `FafbClient::publish()` sends `Frame::Publish` and waits for `Frame::PublishAck`, returning `PublishResult { message_id }`. `publish_no_ack()` sends the frame and drains the pending ack (5s timeout) so subsequent operations on the same client see a clean stream.
- `FafbClient::fetch()` sends `Frame::Fetch`, `fetch_from()` sends `Frame::FetchFrom`. Both use shared `recv_fetch_response()` that handles non-fatal error frames (e.g. `OffsetNotAvailable`) as warnings before the `FetchResponse`.
- `FetchResult` includes `warning: Option<String>` for clamped-offset notifications.
- `PubArgs` expanded with `file`, `lines`, `ttl_secs`, `headers`, `no_ack`. `FetchArgs` expanded with `from`, `since`, `count`, `all`, `payload_only`.
- `Command::Pub` and `Command::Fetch` in `main.rs` have full clap `#[arg]` annotations.
- `GlobalOpts` derives `clap::Args` with `#[arg(global = true)]` — all global flags (`--server`, `--api-key`, `--json`, `--verbose`, `--tls`, etc.) work on every subcommand via clap's global flag propagation. `--server` and `--api-key` support env vars via clap's `env` attribute.
- All command runners (`run_publish`, `run_fetch`, stubs) return `Result<(), CliError>` instead of calling `process::exit`. `CliError` enum distinguishes `Error` (exit 1) and `Timeout` (exit 2). `main()` dispatch handles printing and exit codes centrally.
- `build_client_config(config, namespace) -> Result<ClientConfig, CliError>` shared helper extracts the duplicated `CliConfig → ClientConfig` construction from publish.rs and fetch.rs.
- `cli::parse_header()` splits `"Key: Value"` into byte pairs. `cli::parse_timestamp()` handles unix-millis integers, ISO8601 `YYYY-MM-DDTHH:MM:SSZ`, fractional seconds (`.500Z`), and date-only (`YYYY-MM-DD` → midnight UTC). Extracted `parse_date_components()` and `civil_to_days()` helpers (Hinnant algorithm, zero external deps).
- Fetch JSON output uses `payload` (UTF-8 string) or `payload_base64` (binary). Headers serialized as `[{"key","value"/"value_base64"}]`. Built-in base64 encoder (no dependency).
- HTTP mode (`--http` flag) returns clear error message ("not yet implemented — remove --http flag to use TCP"). Deferred to Phase 23.4.
- 6 new integration tests: roundtrip, headers, no-ack, pagination, TTL. 12 new unit tests: header parsing (4), timestamp parsing (6), build_client_config (2).

---

### Phase 23.2a — Fetch Negative Offsets & Tail (Plan 15 Phase E)

**Goal:** Extend `fafb-q fetch` to support negative offsets and add a `--tail` convenience flag, enabling "last N messages" without knowing the head offset.

```bash
# Fetch the last message
fafb-q fetch myns/orders --from -1 --count 1

# Fetch last 10 messages
fafb-q fetch myns/orders --from -10 --count 10

# Shorthand: --tail N (equivalent to --from -N --count N)
fafb-q fetch myns/orders --tail 10

# Tail with JSON output (pipe-friendly)
fafb-q fetch myns/orders --tail 5 --json

# Tail with payload only
fafb-q fetch myns/orders --tail 1 --payload-only
```

- [x] Change `FetchArgs.from` type from `Option<u64>` to `Option<i64>` in `src/cli/mod.rs` and `src/main.rs`
- [x] Add `--tail <N>` flag to `FetchArgs` (`tail: Option<u32>`) — mutually exclusive with `--from` and `--since` via clap `conflicts_with`
- [x] Validation: `--tail 0` is an error ("--tail count must be > 0")
- [x] `--tail N` calls `FafbClient::fetch_tail(topic, N)` — client-side resolution (probe + fetch)
- [x] Negative `--from` calls `FafbClient::fetch_tail(topic, |from|)` — same client-side resolution
- [x] `FafbClient::fetch_tail(topic, count)` added to `src/client.rs` — discovers `head_offset` via probe fetch, then fetches `head - count`
- [x] `--from` uses `allow_hyphen_values = true` in clap so `-1` is parsed as negative number, not a flag
- [x] HTTP mode (`--http`): pass negative offset directly in query param (server handles resolution natively)
- [x] Integration tests: `client_fetch_tail_last_message`, `client_fetch_tail_last_n`, `client_fetch_tail_exceeds_depth`, `client_fetch_tail_empty_topic`

**Implementation notes (completed):**

- No TCP protocol changes. `FafbClient::fetch_tail()` resolves entirely client-side with two standard Fetch round-trips. This avoids adding a capability flag for a convenience feature.
- `allow_hyphen_values = true` on `--from` is required because clap treats `-1` as a flag by default. This clap attribute makes it parse as a negative number.
- `--tail` conflicts with both `--from` and `--since` via clap's `conflicts_with_all`. `--from` also conflicts with `--tail` for bidirectional enforcement.

**Verification:** `fafb-q fetch myns/orders --tail 1` returns the most recent message. `--tail 10` returns the last 10 messages (or fewer if topic has less). Negative `--from` values work equivalently.

---

### Phase 23.3 — Subscribe Command

**Goal:** Implement `fafb-q sub` — streaming subscribe that prints messages as they arrive, like `tail -f` for a queue. Clean shutdown on Ctrl+C.

- [x] Implement `fafb-q sub` command (`src/cli/subscribe.rs`)
  - [x] Default: subscribe from head (new messages only)
  - [x] `--from <offset>` flag: subscribe from specific offset (`SubscribeAt`)
  - [x] `--since <timestamp>` flag: subscribe from timestamp (`SubscribeFrom`)
  - [x] `--group <name>` flag: join consumer group
  - [x] `--count <n>` flag: receive N messages then exit cleanly
  - [x] `--no-ack` flag: receive-only, don't send Ack (for monitoring)
  - [x] Default behavior: auto-ack each message after printing
- [x] Streaming output
  - [x] Human format: `[topic] offset=N ts=ISO8601\npayload\n` per message
  - [x] JSON format: one JSONL object per message (topic, offset, timestamp_millis, payload)
  - [x] Flush stdout after each message for real-time piping
- [x] SIGINT (Ctrl+C) handler
  - [x] On first SIGINT: send `Unsubscribe`, wait for `UnsubscribeAck`, then exit with code 0
  - [x] On second SIGINT: force-close immediately
  - [x] Use `tokio::signal::ctrl_c()` for signal handling
- [x] Implement `FafbClient::send_frame()` / `recv_frame()` — low-level frame I/O
  - [x] Send Subscribe/SubscribeAt/SubscribeFrom frame
  - [x] Receive SubscribeAck
  - [x] Receive Deliver frames in a loop
- [x] Unsubscribe helper — send Unsubscribe, wait for ack with 5s timeout
- [x] Integration test: subscribe from head, publish, receive Deliver, ack, unsubscribe
- [x] Integration test: SubscribeAt from specific offset receives correct messages
- [x] Integration test: `--group` flag joins consumer group, messages distributed across members

**Verification:** `fafb-q sub` streams messages in real-time. Ctrl+C unsubscribes cleanly. Consumer group and offset/timestamp options work. Pipe-friendly JSONL output.

**Implementation notes (completed):**

- `FafbClient` gained `send_frame(Frame)` and `recv_frame() -> Frame` low-level methods for direct frame I/O — used by subscribe and available for future commands.
- Subscribe command uses `tokio::select!` between `recv_frame()` and `tokio::signal::ctrl_c()` for responsive Ctrl+C handling. `shutting_down` bool tracks state — first Ctrl+C sends Unsubscribe inline and continues draining Deliver frames until UnsubscribeAck; second Ctrl+C force-closes. Fresh `ctrl_c()` call per `select!` iteration (one-shot futures can't be re-polled).
- `unsubscribe()` helper (used by `--count`) sends Unsubscribe frame, then loops draining pending Deliver frames until UnsubscribeAck or 5-second deadline — handles the case where server sends buffered Delivers before the ack.
- Auto-ack (default) sends `Frame::Ack { message_id }` after each message is printed. `--no-ack` skips acking for monitoring use cases. During shutdown drain, messages are acked but not printed.
- `--count N` validates N > 0, decrements per message, cleanly unsubscribes after N messages.
- Shared `MessageDisplay` struct and `print_message()` function in `cli/mod.rs` — used by both `fetch.rs` and `subscribe.rs` to avoid duplicated formatting logic. `base64_encode()` also moved to `cli/mod.rs` as a shared utility.
- Output: Human shows `[topic] offset=N ts=ISO8601`, JSON outputs JSONL with `payload`/`payload_base64`/`message_id`, Raw outputs payload bytes. All modes flush stdout after each message. JSON mode emits a `{"type":"subscribed","topic":"...","start_offset":N}` status line on SubscribeAck.
- `--payload-only` flag for raw payload output (piping to other tools).
- GoingAway frame handling: prints server going-away message and exits cleanly.
- `SubArgs` expanded with `from`, `since`, `group`, `count`, `no_ack`, `payload_only`. `Command::Sub` in main.rs expanded with matching clap `#[arg]` annotations.
- 3 new integration tests in tcp_integration.rs: subscribe-and-receive, subscribe-at-offset, subscribe-with-group.
- All 754+ tests pass, zero clippy warnings, clean formatting.

---

### Phase 23.4 — Info, Health & Admin Commands

**Goal:** Implement operational commands that use HTTP endpoints for queue inspection, health checking, and drain management.

- [x] Implement `fafb-q info` command (`src/cli/info.rs`)
  - [x] Topic info: `fafb-q info myns/orders` — uses `GET /v1/{ns}/{topic}/info`
  - [x] Namespace topics: `fafb-q info myns` — uses `GET /v1/{ns}/topics`
  - [x] Human output: formatted table with head/base offset, messages, disk bytes, subscribers, consumer group lag
  - [x] JSON output: raw API response
- [x] Implement `fafb-q health` command (`src/cli/health.rs`)
  - [x] Readiness check (default): `GET /health/ready` — exit 0 if 200, exit 1 if 503
  - [x] Liveness check: `--live` flag → `GET /health/live`
  - [x] Human output: status message; JSON output: raw response body
- [x] Implement `fafb-q drain` command (`src/cli/drain.rs`)
  - [x] Trigger drain: `fafb-q drain --admin-key <key>` → `POST /admin/drain`
  - [x] Custom timeout: `--timeout 60`
  - [x] Status check: `--status` flag → `GET /admin/drain/status`
  - [x] Human output: drain state, active connections, deadline
- [x] Exit code semantics
  - [x] 0 = success / healthy
  - [x] 1 = error / unhealthy / command failure
  - [x] 2 = timeout
- [x] Integration test: `info` command against running test server with published messages
- [x] Integration test: `health` command returns exit 0 for healthy server, exit 1 during shutdown
- [x] Integration test: `drain` trigger and status check
- [x] Shared HTTP client helper (`build_http_client`, `http_base_url`) in `src/cli/mod.rs`
- [x] `reqwest` promoted from dev-dependency to main dependency
- [x] Separate `--http-server` flag and `http_server` config field for HTTP vs TCP address
- [x] Shared `response_json()` helper with non-JSON response fallback
- [x] Shared `extract_error_message()` for consistent error parsing across commands
- [x] Shared `format_bytes()` utility in `cli/mod.rs`
- [x] Drain `--wait` mode that polls `/admin/drain/status` until complete
- [x] Error-case integration tests (nonexistent topic, wrong auth, wrong admin key)
- [x] All 764+ tests pass, zero clippy warnings, clean formatting

**Verification:** `fafb-q info` displays queue state. `fafb-q health` returns meaningful exit codes. `fafb-q drain` wraps admin endpoints correctly.

**Implementation notes (completed):**

- `reqwest` (0.12, `json` + `rustls-tls-manual-roots` features) added to main `[dependencies]` — same config as the prior dev dependency.
- **HTTP vs TCP address separation:** `--http-server` flag (env: `FAFB_HTTP_SERVER`) and `http_server` field in `GlobalOpts`, `CliProfile`, `CliConfig`. Falls back to `--server` value if not set. `http_base_url()` uses `config.http_server`. Profile `overlay()` merges `http_server`.
- `build_http_client(&CliConfig) -> Result<reqwest::Client, CliError>` shared helper builds a `reqwest::Client` with TLS CA cert loading, mTLS client cert/key, and configurable timeout. `http_base_url(&CliConfig)` constructs `http://` or `https://` URL from the HTTP server address.
- `response_json(resp)` tries JSON parse, falls back to wrapping raw text in `{"message": "..."}` so callers always get a `serde_json::Value`. `extract_error_message(body)` checks `error.message`, `message`, `error` (string) patterns consistently.
- `format_bytes(u64)` in `cli/mod.rs` — shared human-readable byte formatting (B/KB/MB/GB).
- `DrainArgs` expanded with `admin_key: Option<String>` (env: `FAFB_ADMIN_API_KEY`), `timeout: Option<u64>`, `status: bool`, `reason: Option<String>`, `wait: bool`. `HealthArgs` expanded with `live: bool`. `Command::Drain` and `Command::Health` in main.rs updated to struct variants with clap `#[arg]` annotations.
- `info.rs`: Parses target — if contains `/`, calls `GET /v1/{ns}/{topic}/info`; otherwise calls `GET /v1/{ns}/topics`. Human output shows depth, offsets, bytes, subscriber count, timestamps. JSON passes API response through. Uses `response_json()` + `extract_error_message()`.
- `health.rs`: Default `GET /health/ready`, `--live` → `GET /health/live`. Distinguishes timeout, connection refused, and other errors. Returns exit code 1 for unhealthy (503). Uses `response_json()` for non-JSON fallback.
- `drain.rs`: Default triggers `POST /admin/drain` with optional JSON body (`reason`, `timeout_secs`). `--status` checks `GET /admin/drain/status`. `--wait` polls status every 1s until `shutting_down` or CLI timeout. Handles 403 (bad admin key), 409 (already draining), server disconnect (drain complete). Uses `response_json()` + `extract_error_message()`.
- 8 integration tests in `deploy_integration.rs`: `cli_info_topic`, `cli_info_namespace_topics`, `cli_health_ready`, `cli_health_returns_503_during_drain`, `cli_drain_trigger_and_status`, `cli_info_nonexistent_topic`, `cli_info_wrong_auth`, `cli_drain_wrong_admin_key`.
- 11 new unit tests: `http_base_url_uses_http_server`, `http_base_url_uses_https_when_tls`, `resolve_config_http_server_falls_back_to_server`, `resolve_config_http_server_explicit`, `extract_error_message_nested/flat/string_error/unknown`, `format_bytes_values`, `profile_overlay_includes_http_server`.

---

### Phase 23.5 — Config Utilities & Benchmark Commands

**Goal:** Add config validation/dump utilities and a simple built-in benchmark tool for quick performance testing.

- [x] Implement `fafb-q config` command (`src/cli/config.rs`)
  - [x] `validate --file <path>`: load and validate config file, print errors or "valid"
  - [x] `dump [--file <path>]`: load local config file and print resolved values (searches standard paths if no file specified)
  - [x] `dump --admin-key <key>`: fetch running server config via `GET /admin/config` (remote dump)
  - [x] `example`: print the example config to stdout (embed `config/config.example.toml` at compile time via `include_str!`)
- [x] Implement `fafb-q bench` command (`src/cli/bench.rs`)
  - [x] `bench pub <ns/topic>` — publish benchmark
    - [x] `--messages <n>` (default 10000)
    - [x] `--payload-size <bytes>` (default 256)
    - [x] `--concurrency <n>` (default 1) — parallel TCP connections
    - [x] `--no-ack` — fire-and-forget pipelined publish for raw throughput testing
    - [x] Output: total time, throughput (msg/s, MB/s), latency percentiles (p50, p99, p999)
    - [x] Progress output every 2s for long-running benchmarks (human mode, stderr)
  - [x] `bench sub <ns/topic>` — consume benchmark
    - [x] `--group <name>` — consumer group
    - [x] `--from <offset>` — subscribe from specific offset (SubscribeAt) for historical consumption
    - [x] `--count <n>` — auto-stop after N messages (scriptable, no Ctrl+C required)
    - [x] Consume as fast as possible, report throughput
    - [x] Progress output every 2s (human mode, stderr)
  - [x] `bench latency <ns/topic>` — end-to-end latency benchmark
    - [x] Publish + subscribe on two connections, measure round-trip
    - [x] `--messages <n>` (default 1000)
    - [x] `--warmup <n>` (default 0) — discard initial samples for representative percentiles
    - [x] Output: latency percentiles
- [x] Deduplicated `ConfigAction`/`BenchAction` enums — single definition in `cli/mod.rs` with `#[derive(clap::Subcommand)]`, imported in `main.rs`
- [x] `bench` with no subcommand shows clap help (`subcommand_required = true` + `arg_required_else_help = true`)
- [x] No new dependencies needed (plain text formatting, no `comfy-table` or `indicatif`)
- [x] Integration test: `config validate` with valid, invalid, and nonexistent config files
- [x] Integration test: `config dump` with local file and remote server
- [x] Integration test: `config example` outputs embedded config
- [x] Integration test: `bench pub` (single, concurrent, and no-ack modes)
- [x] Integration test: `bench sub` with `--count` and `--from` auto-stop
- [x] Unit tests: latency stats percentiles, format_duration
- [x] All 770+ tests pass, zero clippy warnings, clean formatting

**Verification:** Config utilities work for validation and inspection. Benchmark commands produce throughput and latency statistics.

**Implementation notes (completed):**

- `src/cli/config.rs` — `run_validate`, `run_dump`, `run_dump_remote`, `run_example`. Validate uses `ServerConfig::load()`. Dump supports local files and remote fetch via `GET /admin/config` with `--admin-key`. Example uses `include_str!`.
- `src/cli/bench.rs` — `run_pub_bench`, `run_sub_bench`, `run_latency_bench`. Pub bench: `--concurrency` via `JoinSet`, `--no-ack` for pipelined fire-and-forget throughput (drains acks before close to ensure server processes all publishes). Sub bench: `--from <offset>` (SubscribeAt), `--count <n>` (auto-stop), Ctrl+C shutdown, progress output every 2s. Latency bench: `--warmup <n>` discards initial samples.
- `ConfigAction` and `BenchAction` enums defined once in `cli/mod.rs` with `#[derive(clap::Subcommand)]` — imported directly in `main.rs`, eliminating the duplicate enum pattern from earlier phases. `Bench` uses `subcommand_required = true` + `arg_required_else_help = true` for proper clap help on no subcommand.
- `ConfigAction::Dump` has `admin_key: Option<String>` for remote fetch. `BenchAction::Pub` has `no_ack: bool`. `BenchAction::Sub` has `from: Option<u64>` and `count: Option<u64>`. `BenchAction::Latency` has `warmup: u64`.
- Progress output: pub bench prints `sent N/M...` to stderr every 2s (human mode). Sub bench prints `consumed N messages...` periodically.
- `LatencyStats` computes p50/p99/p999/max. `format_duration()` displays as `Nus`, `N.NNms`, or `N.NNs`.
- 6 unit tests, 10 integration tests (config validate×3, config dump local, config dump remote, config example, bench pub, bench pub concurrent, bench pub no-ack, bench sub with count+from).

---

### Phase 23.6 — Profiles, Output Polish & Shell Completions

**Goal:** Add named profiles for multi-environment use, auto-detect TTY for output format, add color, and generate shell completions.

- [x] Implement named profiles in CLI config file
  - [x] `[profiles.production]`, `[profiles.staging]` sections in `~/.fafb-q/config.toml`
  - [x] `--profile <name>` flag selects a profile
  - [x] Profile values override `[default]` section; CLI flags override profile values
  - [x] `api_key_env` field: read API key from a named env var (avoids storing secrets in config)
- [x] TTY auto-detection for output format
  - [x] If stdout is a TTY (`std::io::stdout().is_terminal()`): default to `Human` format
  - [x] If stdout is piped: default to `Json` format
  - [x] `--json` and `--payload-only` explicitly override auto-detection
- [x] Colored output for human mode (using `colored` crate)
  - [x] Metadata (offset, topic, timestamp) in dim/grey
  - [x] Errors in red
  - [x] Success messages in green
  - [x] Respect `NO_COLOR` env var (standard convention)
- [x] `--verbose` frame-level debugging
  - [x] Print raw protocol frames sent/received (for debugging connection issues)
  - [x] Format: `>>> Frame::Connect { ... }` / `<<< Frame::ConnectAck { ... }`
- [x] Shell completion generation
  - [x] `fafb-q completions bash > /etc/bash_completion.d/fafb-q`
  - [x] Support bash, zsh, fish, powershell, elvish via clap's built-in `clap_complete`
- [x] Add `colored = "3"` dependency
- [x] Add `clap_complete = "4"` dependency
- [x] `--color=auto|always|never` flag via `ColorMode` enum and `apply_color_mode()`
- [x] Unit tests: profile selection, API key env resolution, verbose flag propagation, frame summary formatting, completions generation, color flag parsing
- [x] All 790+ tests pass (779 lib + 11 bin), zero clippy warnings, clean formatting

**Verification:** Multi-environment workflow works via profiles. Output is colored and readable in TTY, machine-parseable in pipes. Shell completions install correctly.

**Implementation notes (completed):**

- **Profiles** were already fully implemented in Phase 23.1: `CliProfile`, `CliConfigFile.profiles`, `overlay()`, `--profile` flag, `api_key_env` resolution in `resolve_cli_config()`. Phase 23.6 added unit tests to confirm profile selection, inheritance, and `api_key_env` resolution.
- **TTY auto-detection** was already implemented in Phase 23.1: `OutputFormat::auto_detect()` uses `std::io::IsTerminal::is_terminal(&std::io::stdout())`. Phase 23.6 added a unit test confirming piped output defaults to JSON.
- **Colored output** uses `colored = "3"` crate. `Colorize` trait imported in each CLI command module. Human-mode output: topic brackets cyan, metadata (offset, timestamp) dimmed, success messages green, errors red+bold, status labels dimmed. All CLI commands updated: `publish.rs`, `subscribe.rs` (via `print_message`), `fetch.rs` (via `print_message`), `info.rs`, `health.rs`, `drain.rs`, `bench.rs`, `config.rs`, `main.rs` (error dispatch). `NO_COLOR` env var respected automatically by `colored` crate's `SHOULD_COLORIZE` mechanism. `--color=auto|always|never` flag: `ColorMode` enum with `clap::ValueEnum`, `apply_color_mode()` calls `colored::control::set_override()` for `always`/`never`. Applied in `main()` before any output.
- **Verbose frame logging** adds `verbose: bool` to `ClientConfig` and `FafbClient`. `send_frame()` and `recv_frame()` log to stderr (`>>> Frame::...` / `<<< Frame::...`). `connect()` also logs the Connect/ConnectAck handshake when verbose. `format_frame_summary()` uses truncated `Debug` output (256 char cap) for all frames, with `Connect` special-cased to redact the API key. Automatically covers new frame variants without maintenance. `build_client_config()` in `cli/mod.rs` passes `config.verbose` through.
- **Shell completions** via `clap_complete = "4"`. `Command::Completions { shell: clap_complete::Shell }` variant added to the Command enum. `fafb-q completions <shell>` generates completions to stdout for bash, zsh, fish, powershell, elvish. Handled before the main dispatch (no `CliConfig` needed).
- 12 new unit/bin tests: profile overrides, profile inheritance, api_key_env resolution, TTY fallback, verbose flag propagation, frame summary (connect-redacts-api-key/debug-fallback/truncation/error), completions generation (bash/zsh/fish), color flag parsing (auto/always/never).

---

### Phase 23.7 - Documentation & Test Coverage

**Goal**: A complete `docs/cli.md` document with all capabilities, and fill integration test gaps identified during review.

- [x] Write a comprehensive and complete `docs/cli.md` document for developers to understand the available cli tooling in this project
- [x] Add `Permission::Fetch` to deploy test server helpers (both `start_deploy_test_server` and `start_admin_test_server`)
- [x] Integration test: `cli_pub_string_payload` — publish string via CLI runner, verify via HTTP info
- [x] Integration test: `cli_pub_with_headers_and_ttl` — publish with headers + TTL, verify roundtrip
- [x] Integration test: `cli_pub_no_ack_mode` — fire-and-forget publish
- [x] Integration test: `cli_pub_from_file` — publish from `--file`, verify content roundtrip
- [x] Integration test: `cli_fetch_from_offset` — fetch via CLI runner with `--from`
- [x] Integration test: `cli_fetch_all_pagination` — fetch `--all` with small page size forces 3 pages
- [x] Integration test: `cli_fetch_since_timestamp` — fetch with `--since` timestamp mode
- [x] Integration test: `cli_health_live` — liveness probe succeeds normally and during drain
- [x] Integration test: `cli_bench_latency` — end-to-end latency benchmark (20 messages, 5 warmup)
- [x] Integration test: `cli_drain_wait_completes` — drain with `--wait` mode completes
- [x] All 800+ tests pass (779 lib + 11 bin + 51 deploy + 79 tcp), zero clippy warnings, clean formatting

**Verification:** `docs/cli.md` covers all 10 commands with flags, examples, output samples. Quick start, global flags, profiles, TLS, verbose mode, timestamps, piping, binary payloads, troubleshooting, and cross-references documented. All previously-identified test gaps (pub command, fetch command, health --live, bench latency, drain --wait) now have integration test coverage.

**Implementation notes (completed):**

- `docs/cli.md` (815 lines): comprehensive CLI reference with quick start, global flags table, all 10 commands (`serve`, `pub`, `sub`, `fetch`, `info`, `health`, `drain`, `config`, `bench`, `completions`) with arguments, flags, examples, and output samples. Sections: output formats, exit codes, topic notation, configuration file with profiles, API key resolution order, TLS/mTLS setup, verbose mode, timestamp formats, piping/scripting cookbook, binary payload encoding table, HTTP vs TCP comparison, troubleshooting (6 common errors), and related documentation cross-references.
- `serve` command documented with all 6 flags (`--config`, `--dump-config`, `--tcp-bind`, `--http-bind`, `--data-dir`, `--log-level`) and 4-tier config file discovery path.
- Binary payloads section explains `payload` vs `payload_base64` and `value` vs `value_base64` across Human/JSON/Raw output modes.
- Drain `--timeout` (server-side) vs global `--timeout` (CLI-side) ambiguity explicitly documented.
- 10 new integration tests in `deploy_integration.rs` covering pub (4), fetch (3), health (1), bench latency (1), drain --wait (1). Deploy test servers gained `Permission::Fetch` to support CLI fetch tests.

---

### Phase 23.8 — API Key Management Commands

**Goal:** Add a `keys` subcommand group for programmatic API key management. All commands use HTTP to hit the admin key management endpoints (Plan 25 Phase I). Requires `--admin-key` for authentication.

#### Command Structure

```
fafb-q keys <subcommand> [args] --admin-key <key>

Subcommands:
  list     List all keys in a namespace
  create   Create a new named key
  get      Get details of a specific key
  update   Update permissions on a runtime key
  revoke   Revoke a runtime key
```

#### `keys list` — List Keys

```bash
fafb-q keys list <namespace> --admin-key <key>
```

**Human output:**
```
NAME             SOURCE   PERMISSIONS                      CREATED              EXPIRES
_seed:default    config   Subscribe, Publish, Query, Fetch 2026-03-29T00:00:00Z -
ci-publisher     runtime  Publish                          2026-03-29T10:15:30Z 2026-12-31T23:59:59Z
dashboard-reader runtime  Query, Fetch                     2026-03-29T10:16:00Z -
```

**JSON output:** Raw API response from `GET /admin/v1/namespaces/{ns}/keys`.

#### `keys create` — Create Key

```bash
# Server generates the secret
fafb-q keys create <namespace> <name> --permissions Publish,Query --admin-key <key>

# Caller provides the secret
fafb-q keys create <namespace> <name> --permissions Publish --api-key "my-custom-key-here-00000" --admin-key <key>

# Create with expiration (stored but not enforced)
fafb-q keys create <namespace> <name> --permissions Publish --expires-at "2026-12-31T23:59:59Z" --admin-key <key>
```

**Human output:**
```
Created key "ci-publisher" in namespace "default"
API Key: generated-secret-key-here-00000000
Permissions: Publish

Store this key securely — it will not be shown again.
```

**JSON output:** Raw API response from `POST /admin/v1/namespaces/{ns}/keys` (includes full secret).

The warning line ("Store this key securely") is written to stderr so it doesn't appear in piped JSON output.

#### `keys get` — Get Key Details

```bash
fafb-q keys get <namespace> <name> --admin-key <key>
```

**Human output:**
```
Name:        ci-publisher
Namespace:   default
API Key:     gene...0000
Permissions: Publish
Source:      runtime
Created:     2026-03-29T10:15:30Z
```

**JSON output:** Raw API response from `GET /admin/v1/namespaces/{ns}/keys/{name}`.

#### `keys update` — Update Permissions

```bash
fafb-q keys update <namespace> <name> --permissions Subscribe,Publish,Query --admin-key <key>
```

**Human output:**
```
Updated key "ci-publisher" in namespace "default"
Permissions: Subscribe, Publish, Query
```

**JSON output:** Raw API response from `PUT /admin/v1/namespaces/{ns}/keys/{name}`.

#### `keys revoke` — Revoke Key

```bash
fafb-q keys revoke <namespace> <name> --admin-key <key>
```

**Human output:**
```
Revoked key "ci-publisher" from namespace "default"
```

**JSON output:** Raw API response from `DELETE /admin/v1/namespaces/{ns}/keys/{name}`.

#### Admin Key Convention

All admin-level CLI commands (`drain`, `keys`, and future admin commands) use `--admin-key` for the system-level admin API key, with env var `FAFB_ADMIN_API_KEY`. This is distinct from `--api-key` / `FAFB_API_KEY` which is for namespace-scoped operations. Future admin CLI commands should follow this same pattern.

#### Implementation

- New file: `src/cli/keys.rs`
- New `KeysAction` enum with `clap::Subcommand`: `List`, `Create`, `Get`, `Update`, `Revoke`
- Add `Command::Keys(KeysArgs)` to the main `Command` enum in `main.rs`
- `KeysArgs` struct with `admin_key: String` (env: `FAFB_ADMIN_API_KEY`) and `KeysAction` subcommand
- `--permissions` flag parses comma-separated: `"Publish,Query"` → `vec!["Publish", "Query"]`
- `--expires-at` flag on `create`: optional ISO 8601 timestamp, passed through to admin API. Stored but not enforced.
- All commands use `build_http_client()` and `http_base_url()` from `cli/mod.rs`
- Error handling: 400 → "invalid request", 403 → "unauthorized", 404 → "not found", 409 → "conflict"

### Tasks

- [ ] Define `KeysAction` enum: `List(ListKeysArgs)`, `Create(CreateKeyArgs)`, `Get(GetKeyArgs)`, `Update(UpdateKeyArgs)`, `Revoke(RevokeKeyArgs)`
- [ ] Define `KeysArgs` struct with `admin_key` and `KeysAction` subcommand
- [ ] Add `Command::Keys(KeysArgs)` to main `Command` enum
- [ ] Implement `src/cli/keys.rs` with `run_keys()` dispatch function
- [ ] Implement `run_list_keys()` — `GET /admin/v1/namespaces/{ns}/keys`, human table output
- [ ] Implement `run_create_key()` — `POST /admin/v1/namespaces/{ns}/keys`, show full secret in human output, warning to stderr
- [ ] Implement `run_get_key()` — `GET /admin/v1/namespaces/{ns}/keys/{name}`, human label-value output
- [ ] Implement `run_update_key()` — `PUT /admin/v1/namespaces/{ns}/keys/{name}`, confirm message
- [ ] Implement `run_revoke_key()` — `DELETE /admin/v1/namespaces/{ns}/keys/{name}`, confirm message
- [ ] Parse `--permissions` flag as comma-separated `Vec<Permission>`
- [ ] Map HTTP error codes to user-friendly messages (400, 403, 404, 409)
- [ ] Integration test: `cli_keys_create_and_list` — create key via CLI, list shows it
- [ ] Integration test: `cli_keys_create_and_auth` — create key via CLI, use it to publish via TCP
- [ ] Integration test: `cli_keys_get_shows_redacted_secret` — get key, verify secret is redacted
- [ ] Integration test: `cli_keys_update_permissions` — update permissions, verify via get
- [ ] Integration test: `cli_keys_revoke` — revoke key, verify list no longer includes it
- [ ] Integration test: `cli_keys_revoke_config_key_fails` — revoke config key returns error
- [ ] Integration test: `cli_keys_create_duplicate_name_fails` — duplicate name returns error
- [ ] Unit test: permissions flag parsing (comma-separated, single, all four)
- [ ] Update `docs/cli.md` with `keys` command documentation

**Verification:** All `fafb-q keys` subcommands work correctly. Human and JSON output modes function. Error messages are clear. `docs/cli.md` updated.

---

## Dependencies

```toml
# Already present:
clap = { version = "4", features = ["derive", "env"] }
tokio = { version = "1", features = ["full"] }
serde_json = "1"
reqwest = { version = "0.12", features = ["json", "rustls-tls-manual-roots"] }

# Added in Phase 23.6:
colored = "3"          # colored terminal output
clap_complete = "4"    # shell completion generation
```

> **Note:** `comfy-table` and `indicatif` from the original plan were not needed — plain text formatting with `colored` was sufficient for all commands.

> **Note on TTY detection:** `atty` is deprecated. Use `std::io::IsTerminal` (stable since Rust 1.70). The project uses Rust edition 2024, so this is available: `std::io::stdout().is_terminal()`.

## Testing Strategy

- **Unit tests:** Argument parsing, config file loading, output formatting
- **Integration tests:** Full round-trip: start test server → CLI publish → CLI fetch → verify, CLI subscribe → CLI publish → verify streaming output
- **Script tests:** Shell scripts that exercise pipe workflows (`echo | fafb-q pub`, `fafb-q fetch | jq`)
- **Manual testing:** Interactive subscribe, Ctrl+C behavior, TTY vs pipe detection

## FafbClient and Codec Reuse

The `FafbClient` connects using the same `MessageCodec` that the server uses (`src/codec/`). This is a key advantage of having the CLI in the same crate — the codec is shared, not reimplemented.

```rust
use tokio::net::TcpStream;
use tokio_util::codec::Framed;
use crate::codec::MessageCodec;

pub struct FafbClient {
    framed: Framed<TcpStream, MessageCodec>,  // or Framed<TlsStream<TcpStream>, MessageCodec>
    capabilities: Capabilities,
    namespace: NamespaceId,
}
```

**Connection flow:**
1. `TcpStream::connect(addr)` (optionally wrap with TLS)
2. `Framed::new(stream, MessageCodec::new(max_frame_size))`
3. Send `Frame::Connect { version, namespace, api_key, capabilities }`
4. Receive `Frame::ConnectAck { server_capabilities }` — store negotiated capabilities
5. Ready for commands

**Graceful close:**
1. On Ctrl+C (SIGINT): send `Unsubscribe` for active subscriptions, wait for acks
2. Drop the framed stream (TCP FIN sent)

## Risks and Mitigations

| Risk | Mitigation |
|---|---|
| `main.rs` restructuring is a large refactor (~800 lines) | Phase 23.1 does this first, with zero behavioral changes to the server. All existing tests must pass before adding CLI commands. |
| Backward compatibility: `fafb-q --config foo.toml` becomes `fafb-q serve --config foo.toml` | `Command` is `Option` — `None` defaults to `Serve`. `fafb-q --config foo.toml` still works because clap will match it to the default `Serve` subcommand. Test this explicitly. |
| CLI binary size increases with new dependencies | Total increase ~50-100KB. Negligible for CLI/operator use. If needed, gate behind `features = ["cli"]`. |
| `FafbClient` duplicates server-side connection logic | It uses the same `MessageCodec` and `Frame` types — no duplication. The Connect/Auth flow is a thin layer on top of the shared codec. |
| CLI subscribe holds a connection open indefinitely | Document that `fafb-q sub` is for debugging/tailing, not production consumption. Production consumers should use the SDK or direct TCP integration. |
| Benchmark results are not comparable across machines | Document that `fafb-q bench` is for relative comparison (before/after changes), not absolute performance claims. |
| `--no-ack` publish mode has no backpressure | The TCP connection still has kernel buffer backpressure. If the server is overwhelmed, the TCP send will block at the OS level. Document this. |

## Out of Scope

- **Separate client SDK crate** — `FafbClient` is the foundation for a future `fafb-q-client` crate, but extracting it into a separate crate is a separate effort.
- **GUI / TUI** — The CLI is text-based. No terminal UI (ratatui, etc.).
- **Plugin system** — No CLI plugin mechanism. Commands are compiled-in.
- **Auto-update** — No self-update mechanism. Users update via package manager or binary download.
- **Completion for topic/namespace names** — Dynamic shell completion that queries the server for topic names. Would require a running server connection during tab-completion, which is complex. Static completion for flags/commands only.
