youtube-legend-cli 0.4.0

Non-interactive Rust CLI that downloads YouTube subtitles through third-party providers, using a native Unix stdin/stdout interface.
# AGENTS

> A native-Unix subtitle fetcher that gives agents full control of the byte stream.

Languages: [English]AGENTS.md | [Português Brasileiro]AGENTS.pt-BR.md


## Why
- You are an agent, not a human, and this CLI takes one URL in and hands one subtitle out on plain `stdin` and `stdout`
- There is no prompt, no TUI and no daemon to babysit
- You already speak JSON, so `--json` hands you a typed envelope instead of free text
- You already speak exit codes, and this CLI returns BSD `sysexits.h` numbers
- Your error handler branches on a category without inventing a mapping of its own


## Economy
- One subtitle download costs far fewer tokens than scraping the watch page and pulling the captions out of the HTML by hand
- The provider chain returns the subtitle payload directly, in the language you asked for and in the format you asked for
- The local file cache keys on `video_id`, `language` and `format`
- A repeated request for the same video in the same session is served from disk instead of from the network
- The JSON envelope is one line, so your parser reads it once and your context holds only the body


## Sovereignty
- The binary is a single Rust artifact with no runtime dependency, no container, no cloud and no background process
- Zero telemetry: the CLI never phones home, never sends analytics and never checks for updates
- The only outbound traffic is the HTTP request to the chosen provider, scoped to the video you asked for
- `--offline` refuses every outbound request and answers from the local cache only
- The `secret_endpoints` module is declared `pub(crate)` at `src/lib.rs:110`, which keeps it out of the published rustdoc and out of the public API
- `SIGINT` and `SIGTERM` are cooperative and cancel in-flight work at the next `await` point
- MEASURED on 2026-09-04 in `src/main.rs`: a run cut short by `SIGINT` exits `130`, a run cut short by `SIGTERM` exits `143`, and a run whose reader closed the pipe exits `141`
- A second signal during shutdown forces an immediate process exit


## Compatible Agents
- Claude Code — pass a URL, capture the JSON envelope on `stdout`, branch on the exit code
- Aider — call the CLI from a shell command block, parse the `--json` output, feed the body back into the next edit
- Codex CLI — spawn the binary as a subprocess, read `stdout`, treat `stderr` as diagnostic only
- Cline — use the CLI as a tool, pass a URL, capture the result, never let it touch the watch page HTML
- Any LLM agent with a `bash` tool, because the interface is plain Unix


## Architecture at a Glance
- A single clap-derived `Cli` struct captures every flag
- `commands::run` dispatches to `extract::run` for a single URL and to `batch::run` for stdin-driven lists
- The provider chain holds two plain-HTTP providers, and `auto` walks both of them
- The cache layer stores every successful fetch on disk
- The output layer writes plain text or the JSON envelope to `stdout`
- Logs and progress go to `stderr` and never to `stdout`
- `src/provider/chain/ledger.rs` owns the per-attempt record that the error envelope publishes as `attempts`
- `src/provider/chain/watch_probe.rs` owns the watch-page probe consulted once, after the chain is exhausted
- `src/provider/mod.rs` holds the `Provider` trait, the language negotiation and the re-exports, and is NOT a facade


## Command Surface
### REQUIRED
- Know that the binary exposes exactly three subcommands: `config`, `completions` and `man`
- Use `config` to inspect and edit the configuration file
- Use `completions <SHELL>` to print the shell completion script on `stdout`
- Use `man` to print the section 1 manual page on `stdout`, in roff format
- Pass the URL as the single positional argument, in watch, shorts, embed or `youtu.be` form
### FORBIDDEN
- Do not invoke a fourth subcommand, because none exists and clap exits `2`
- Do not invoke `config discover`, which was removed in 2026-09-04
### Correct Pattern
```bash
youtube-legend-cli --json "https://youtu.be/dQw4w9WgXcQ"
youtube-legend-cli man > ~/.local/share/man/man1/youtube-legend-cli.1
```


## Configuration Subcommands
### REQUIRED
- Know that `config` exposes exactly six subcommands
- Use `config path` to print the absolute path of the configuration file
- Use `config show` to print every key currently set, in dotted form
- Use `config get <KEY>` to print the value of one key
- Use `config set <KEY> <VALUE>` to set one key, validated against the registry
- Use `config unset <KEY>` to remove one key and restore the compiled default
- Use `config list-keys` to print the whole registry as key, type and description
### FORBIDDEN
- Do not derive the registry size from any document, because only `config list-keys` is current
- Do not write the TOML file by hand when `config set` validates the value for you
### Correct Pattern
```bash
youtube-legend-cli config path
youtube-legend-cli config list-keys
youtube-legend-cli config set log_format json
youtube-legend-cli config get log_format
youtube-legend-cli config unset log_format
youtube-legend-cli config show
```


## Shell Completions
### REQUIRED
- Know that `completions` accepts exactly five shells: `bash`, `elvish`, `fish`, `powershell` and `zsh`
- Redirect the script to the completion directory your shell reads
- Trust the script, because it derives from the same clap command tree the binary parses
### FORBIDDEN
- Do not pass a sixth shell name, because clap rejects it and exits `2`
- Do not hand-edit the generated script, because the next release regenerates it
### Correct Pattern
```bash
youtube-legend-cli completions bash > ~/.local/share/bash-completion/completions/youtube-legend-cli
youtube-legend-cli completions zsh > ~/.local/share/zsh/site-functions/_youtube-legend-cli
youtube-legend-cli completions fish > ~/.config/fish/completions/youtube-legend-cli.fish
youtube-legend-cli completions elvish > ~/.config/elvish/lib/youtube-legend-cli.elv
youtube-legend-cli completions powershell > youtube-legend-cli.ps1
```


## Global Flags
### Complete List
- `--lang <LANG>` — preferred subtitle language, ISO 639-1 or BCP 47, default `en`
- `--ui-lang <LANG>` — interface language for the messages written to `stderr`
- `--format <FORMAT>``txt`, `srt` or `vtt`, default `txt`
- `--timeout <SECONDS>` — ceiling for the whole operation, default `300`
- `--verbose` — emit tracing events to `stderr`
- `--quiet` — suppress all `stderr` output except errors
- `--config <PATH>` — path to a TOML config file
- `--log-level <LEVEL>``error`, `warn`, `info`, `debug` or `trace`, default `warn`
- `--log-format <FORMAT>``text` or `json`, default `text`
- `--color <WHEN>``auto`, `always` or `never`, default `auto`
- `--no-progress` — suppress progress bars on `stderr`
- `--dry-run` — skip network I/O and serve reads from cache only
- `--yes` — assume yes for any confirmation prompt
- `--json` — emit the structured JSON envelope to `stdout`
- `--batch` — read multiple URLs from `stdin`, one per line
- `--resume` — skip URLs whose output a previous `--batch` run already emitted
- `--offline` — refuse every outbound request and serve reads from the local cache only
- `--jobs <N>` — batch items processed concurrently, default `0`, which derives the count from the host
- `--user-agent <STRING>` — custom User-Agent for HTTP requests
- `--cache-ttl <HOURS>` — local cache TTL in hours, default `24`
- `--no-cache` — disable reads from the local cache
- `--provider <PROVIDER>``auto`, `provider-decopy` or `provider-noiz`, default `auto`
- `--no-input` — refuse `stdin` and fail fast instead of blocking
- `--print-schema` — print the JSON Schema of every output surface and exit
- `--select <KEYS>` — keep only these dotted keys, comma-separated, alias `--fields`
- `--filter <EXPR>` — filter elements by `key=value`, `key!=value` or `key~substring`
- `--limit <N>` — emit at most N elements
- `--sort <KEY>` — sort ascending by a dotted key
- `--dedupe-by <KEY>` — drop later elements repeating this key's value
- `--count-only` — replace the payload with a count
- `--truncate-content <N>` — shorten strings above N characters, never bytes
- `--max-output-bytes <N>` — cap the envelope at N bytes by dropping whole elements
- `-h`, `--help` — print help
- `-V`, `--version` — print the version
### REQUIRED
- Use `--json` whenever a downstream consumer parses the output
- Pass `--lang` with a BCP 47 tag such as `pt-BR`, `en-US` or `pt_BR.UTF-8` when you need a specific language, because the default `en` is a guess
- Raise `--timeout` above the default `300` only when a pipeline genuinely runs longer, because that ceiling wraps the whole provider chain and every retry
- Reach for `--select`, `--filter`, `--limit` and `--max-output-bytes` before piping the envelope through another tool
### FORBIDDEN
- Do not claim `--timeout` defaults to 30 seconds, because that default was raised to `300` and a shorter ceiling fired before a provider could answer
- Do not hardcode the provider hostname in agent code, because the hostnames live in the `pub(crate)` `secret_endpoints` module and in the `net.endpoints.*` configuration keys
- Do not parse `stderr`, because the structured data lives on `stdout` when `--json` is set
- Do not hardcode one provider as if it were the only one, because there are two and `auto` walks both
### Correct Pattern
```bash
youtube-legend-cli --json --lang pt-BR --timeout 300 "https://youtu.be/dQw4w9WgXcQ" \
  | jaq -r '.content'
```


## Providers
### REQUIRED
- Know that `--provider` accepts exactly two providers plus `auto`
- Use `provider-decopy` when you accept the native track only, because it offers no language choice
- Use `provider-noiz` sparingly, because it is subject to a five-per-day quota
- Let `auto` walk the whole chain in cost order, which is the default
- Pin one provider with `--provider` when a run must be deterministic
- Expect a pinned provider that fails to exit `69` instead of falling back in silence
### FORBIDDEN
- Do not pass `--provider youtube-direct`, `--provider provider-a`, `--provider provider-b` or `--provider provider-headless`, because they were removed in v0.3.2 and clap exits `2`
- Do not pass `--provider provider-getsubs` or `--provider provider-noteey`, because they were removed on 2026-09-04
- Do not pass `--no-fallback`, because that flag was removed in v0.3.2
- Do not pass `--asr`, because the flag and the `asr` configuration key were both removed
- Do not build a test suite or a batch on `provider-noiz` without accounting for its quota
### Correct Pattern
```bash
youtube-legend-cli --provider auto "https://youtu.be/VIDEO"
youtube-legend-cli --provider provider-decopy "https://youtu.be/VIDEO"
youtube-legend-cli --provider provider-noiz "https://youtu.be/VIDEO"
```


## JSON Success Envelope
### REQUIRED
- Parse `provider`, `video_id`, `target_resolved`, `target_source`, `language`, `delivered_language`, `format`, `content`, `byte_size`, `duration_ms` and `source_url` as typed fields
- Read `language` as the request you ECHOED back, because that field is the ask and not the observation
- Read `delivered_language` as the only field that reports the track actually delivered
- Read `target_source` to tell an `argv` URL apart from a `batch-file` URL
### FORBIDDEN
- Do not look for a `language_detected` field, because the envelope has never carried one
- Do not look for an `ok` field, because success is signalled by the absence of `error`
- Do not extract any of these fields with a regular expression
### Correct Pattern
```json
{
  "provider": "cache",
  "video_id": "dQw4w9WgXcQ",
  "target_resolved": "https://youtu.be/dQw4w9WgXcQ",
  "target_source": "argv",
  "language": "en",
  "delivered_language": "en",
  "format": "txt",
  "content": "...",
  "byte_size": 2320,
  "duration_ms": 0,
  "source_url": "cache"
}
```


## JSON Error Envelope
### REQUIRED
- Branch on `error`, which is a BOOLEAN whose value is `true` on every failure
- Read `code`, `message`, `kind`, `retryable`, `provider`, `video_id`, `target_resolved`, `target_source`, `requested_language`, `available_languages` and `attempts` as TOP-LEVEL fields
- Read the error envelope on `stdout` under `--json`, where the CLI writes it
- Retry only when `retryable` is `true`
- Read `attempts` to learn which provider was tried, in what order and with which outcome
### FORBIDDEN
- Do not index `.error.kind`, because `error` is a boolean and indexing it FAILS
- Do not look for an object named `error`, because none exists
- Do not look for an `ok` field, because none exists
- Do not read the error envelope from `stderr`, because it never appears there
### Correct Pattern
```bash
youtube-legend-cli --json --lang xx "https://youtu.be/dQw4w9WgXcQ" | jaq -r '.kind'
```

```json
{
  "error": true,
  "code": 66,
  "message": "no subtitle: the requested language is unavailable",
  "kind": "language_unavailable",
  "retryable": false,
  "video_id": "dQw4w9WgXcQ",
  "target_resolved": "https://youtu.be/dQw4w9WgXcQ",
  "target_source": "argv",
  "requested_language": "xx",
  "available_languages": ["de-DE", "en", "es-419", "ja", "pt-BR"],
  "attempts": [
    {"provider": "provider-decopy", "outcome": "rate_limited", "elapsed_ms": 303},
    {"provider": "provider-noiz", "outcome": "delivered", "elapsed_ms": 697, "body_len": 4293},
    {"provider": "watch-page", "outcome": "language_unavailable", "elapsed_ms": 369}
  ]
}
```


## Exit Codes
### REQUIRED
- Branch on `0` for success
- Branch on `2` for a command line clap refused, such as an unknown `--provider` value
- Branch on `64` for `EX_USAGE`, raised by a post-parse usage error or by empty `stdin`
- Branch on `65` for `EX_DATAERR`, raised by an unparseable URL
- Branch on `66` for `EX_NOINPUT`, raised by `no_captions` and by `language_unavailable`
- Branch on `69` for `EX_UNAVAILABLE`, raised by `provider_rate_limited`, by an unavailable provider and by a captcha challenge
- Branch on `70` for `EX_SOFTWARE`, which is a defect on this side
- Branch on `74` for `EX_IOERR`, raised by local I/O
- Branch on `75` for `EX_TEMPFAIL`, raised by the operation timeout
- Branch on `76` for `EX_PROTOCOL`, raised when the upstream answered something this crate could not interpret
- Branch on `78` for `EX_CONFIG`, raised by a missing, unreadable or malformed TOML file
- Branch on `130` for a run cut short by `SIGINT`
- Branch on `141` for a run whose `stdout` reader closed the pipe
- Branch on `143` for a run cut short by `SIGTERM`
- Read the exit of a `--batch` run as the exit of the WORST item, never of the first
- Use `AppError::exit_code()` when you consume the Rust API directly, because the mapping is the same table
### FORBIDDEN
- Do not collapse `130` and `143` onto one code, because an orchestrator's `kill` must stay distinguishable from an operator's Ctrl-C
- Do not treat `69` as fatal, because the upstream was unavailable and a backoff retry is the answer
- Do not treat `75` as fatal, because the same invocation later may well succeed
- Do not hardcode raw integers in a shell dispatcher without naming the category next to them
### Correct Pattern
```bash
youtube-legend-cli --json "https://youtu.be/VIDEO"
case $? in
  0)   handle_success ;;
  2|64|65|78) handle_user_error ;;
  66)  handle_no_subtitle ;;
  69)  handle_upstream_unavailable ;;
  74)  handle_io_error ;;
  75)  handle_transient_retry_later ;;
  76)  handle_upstream_protocol_change ;;
  70)  handle_internal_error ;;
  130|143) handle_signal ;;
  141) handle_broken_pipe ;;
  *)   handle_unknown ;;
esac
```


## Cache
### REQUIRED
- Use the default 24-hour TTL, which the `--cache-ttl` flag and the `cache_ttl` key both govern
- Know that the cache lives in the platform cache directory resolved by the `directories` crate, which is `~/.cache/youtube-legend-cli/` on Linux
- Know that the cache key is the triple of `video_id`, `language` and `format`
- Use `--no-cache` for a one-off read that must reflect the current upstream state
- Use `--offline` when the run must answer from cache and never open a socket
### FORBIDDEN
- Do not hand-edit cache files, because the format is internal and the next run overwrites inconsistent entries
- Do not assume the Linux path on macOS or Windows, because `directories` resolves a different root there
### Correct Pattern
```bash
youtube-legend-cli --cache-ttl 168 "https://youtu.be/VIDEO"
youtube-legend-cli --no-cache "https://youtu.be/VIDEO"
youtube-legend-cli --offline "https://youtu.be/VIDEO"
```


## Retry and Rate Limiting
### REQUIRED
- Let the CLI honour the `Retry-After` header, which `retry::retry_with_backoff` already parses in both delta-seconds and HTTP-date form
- Tune the retry policy through the `net.retry.*` configuration keys rather than through a loop of your own
- Treat `AppError::RateLimited` as transient, because the provider recovers and the chain retries
### FORBIDDEN
- Do not add a custom retry loop in agent code, because nesting retries causes stampedes
- Do not pin `--timeout` below the time one provider phase needs, because a tight ceiling produces failures the provider never caused
### Correct Pattern
```bash
youtube-legend-cli "https://youtu.be/VIDEO"
youtube-legend-cli config get net.retry.max_attempts
```


## Streaming Contracts
### REQUIRED
- Treat `stdout` as the subtitle body, or as the JSON envelope when `--json` is set
- Treat `stderr` as logs, progress and human error messages
- Capture `stderr` for debugging and never parse it
### FORBIDDEN
- Do not write your own logs to `stdout`, because any non-subtitle byte corrupts the stream downstream
- Do not redirect `stderr` to `/dev/null`, because you lose the failure reason when the exit code is non-zero
### Correct Pattern
```bash
youtube-legend-cli "https://youtu.be/VIDEO" > subtitle.txt 2> run.log
```


## Error Handling in the Rust API
### REQUIRED
- Map `AppError` to a category, because the enum is `#[non_exhaustive]`
- Use the `reason()` helper to extract the inner `NoSubtitleReason` when the error is `NoSubtitle`
- Know that the default branch of `reason()` returns `NotPublished`
### FORBIDDEN
- Do not call `panic!` on `AppError`, because every failure has a typed variant and a human-readable message
- Do not stringify the error and match on substrings, because the typed variants are the contract
### Correct Pattern
```rust
match err {
    AppError::NoSubtitle(reason) => tracing::warn!("no subtitle: {reason}"),
    AppError::RateLimited { retry_after_secs } => {
        tokio::time::sleep(Duration::from_secs(retry_after_secs.unwrap_or(60))).await;
    }
    _ => return Err(err),
}
```


## Configuration Instead of Environment Variables
### REQUIRED
- Configure this CLI with a CLI flag or with the TOML file that `config` writes, because the binary reads NO environment variable at all
- Set the log level with `--log-level`, or with `config set log_level <error|warn|info|debug|trace>`
- Set the log format with `--log-format json`, or with `config set log_format json`
- Point the CLI at an alternative TOML file with `--config <PATH>`
- The CLI flag ALWAYS wins over the configuration key, with no exception anywhere in this binary
- MEASURED on 2026-09-04 in `src/logging.rs`: the order is the `--log-level` flag, then the `log_level` key, then the compiled default
- The same order holds for `--log-format` against `log_format`, and for `--color` against `color`
- CORRECTED on 2026-09-04: this bullet said the `log_level` and `log_format` keys WIN over their flags until today
- That was true until 2026-08-31 and it was the defect, not the contract: `--log-level error` used to lose to a `log_level = "trace"` sitting in the config file
- The bullet was written on the day the inversion was fixed and it kept the pre-fix reading, so it taught the inverse of the shipped behaviour for four days
### FORBIDDEN
- Do not set `RUST_LOG` and expect it to win, because it stopped being consulted on 2026-08-31
- Do not set `YT_LOG_LEVEL` or `YT_LOG_FORMAT`, because neither existed in any released version
- Do not teach any environment variable as the way to drive this CLI
### Correct Pattern
```bash
youtube-legend-cli config set log_level info
youtube-legend-cli config set log_format json
youtube-legend-cli --config ./yt-legend.toml "https://youtu.be/VIDEO"
```