Skip to content

Commit 149bf02

Browse files
committed
Add root -v/--verbose flag for stderr diagnostics
The CLI had no way to see what it was doing on the wire: library loggers were actively silenced so stderr stayed clean next to normalized errors, and diagnosing an auth or connectivity problem meant reading code. A new root flag closes that gap (counted, gh/speechmatics style): - `-v` surfaces request-level logs (httpx and friends at INFO) - `-vv` adds wire-level detail (websockets frames, httpcore events at DEBUG) aai_cli/debuglog.py installs one stderr handler with a redacting formatter; config.resolve_api_key and AppState.resolve_session register the API key and session JWT at their resolution choke points so verbose output can never print them in clear (websockets logs the raw Authorization header at DEBUG during the handshake). The realtime silencers (aai_cli.ws, streaming/diagnostics) stand down while verbose mode is active, and the root callback logs the resolved environment. https://claude.ai/code/session_01Q6KZYc7FPWhyJkbQLUtq36
1 parent 410aab3 commit 149bf02

13 files changed

Lines changed: 259 additions & 4 deletions

File tree

.importlinter

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ source_modules =
1616
aai_cli.config
1717
aai_cli.config_builder
1818
aai_cli.context
19+
aai_cli.debuglog
1920
aai_cli.environments
2021
aai_cli.errors
2122
aai_cli.eval_data
@@ -78,6 +79,7 @@ source_modules =
7879
aai_cli.client
7980
aai_cli.config
8081
aai_cli.config_builder
82+
aai_cli.debuglog
8183
aai_cli.environments
8284
aai_cli.errors
8385
aai_cli.eval_data

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ Each file in `aai_cli/commands/` is a Typer sub-app (`transcribe`, `stream`, `ag
173173
- **`environments.py`** — a frozen `Environment` (api_base, streaming_host, llm_gateway_base, ams_base, stytch_*). `DEFAULT_ENV` is **`production`**; use `--sandbox` (or `--env sandbox000` / `AAI_ENV`) to target the sandbox. The active environment is a process-global set once at startup; precedence: `--env``AAI_ENV` → profile's stored env → default. A credential is only valid against the environment that minted it.
174174
- **`client.py`** — thin wrappers over the `assemblyai` SDK (`transcribe`, `list_transcripts`, `stream_audio`, etc.). It normalizes SDK exceptions: auth failures become a single clean `auth_failure()` `CLIError`; everything else becomes `APIError`. New SDK calls should follow this try/except shape.
175175
- **`errors.py`** — the `CLIError` hierarchy (each with `error_type` + `exit_code`). `output.py` emits errors to **stderr**; stdout stays clean for pipelines. `--json` switches to machine-readable output; it is never auto-enabled — `output.resolve_json()` deliberately keeps human text the default even when piped or agent-run.
176+
- **`debuglog.py`** — the root `-v/--verbose` flag (count: `-v` request-level at INFO, `-vv` wire-level at DEBUG). The CLI normally configures no logging, and the realtime paths *silence* library loggers (`ws.py`, `streaming/diagnostics.py`); verbose mode installs one redacting stderr handler and those silencers stand down. Secrets are registered at their resolution choke points (`config.resolve_api_key`, `AppState.resolve_session`) and masked in every rendered record — websockets logs the raw Authorization header at DEBUG, so masking lives in the formatter, not at call sites. Stdlib-only on purpose: `config` (a Rich-free layer) imports it.
176177

177178
### Feature subsystems
178179

aai_cli/config.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import tomli_w
1515
from pydantic import BaseModel, ConfigDict, Field, ValidationError
1616

17+
from aai_cli import debuglog
1718
from aai_cli.errors import CLIError, NotAuthenticated
1819

1920
KEYRING_SERVICE = "assemblyai-cli"
@@ -408,6 +409,18 @@ def set_update_cache(*, last_check: float, latest_version: str | None) -> None:
408409

409410

410411
def resolve_api_key(*, profile: str | None = None, api_key_flag: str | None = None) -> str:
412+
"""The API key for SDK/gateway calls: --api-key flag > ASSEMBLYAI_API_KEY > keyring.
413+
414+
Every resolved key is registered with the verbose-log redactor
415+
(``debuglog.register_secret``) at this single choke point, so ``-v``/``-vv``
416+
diagnostics can never print it in clear no matter which library logs it.
417+
"""
418+
key = _resolve_api_key(profile=profile, api_key_flag=api_key_flag)
419+
debuglog.register_secret(key)
420+
return key
421+
422+
423+
def _resolve_api_key(*, profile: str | None, api_key_flag: str | None) -> str:
411424
# Values are stripped at every tier: a whitespace-only key (e.g. a botched
412425
# `export ASSEMBLYAI_API_KEY=' '`) must read as "no key" (the clean exit-4
413426
# not-signed-in path), not get sent as an illegal HTTP header byte string.

aai_cli/context.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import keyring.errors
1010
import typer
1111

12-
from aai_cli import config, environments, output, telemetry, update_check
12+
from aai_cli import config, debuglog, environments, output, telemetry, update_check
1313
from aai_cli.environments import Environment
1414
from aai_cli.errors import APIError, CLIError, NotAuthenticated
1515

@@ -72,6 +72,9 @@ def resolve_session(self) -> tuple[int, str]:
7272
"can't access account commands."
7373
),
7474
)
75+
# Registered like the API key in config.resolve_api_key: -v/-vv diagnostics
76+
# must never print the session JWT in clear.
77+
debuglog.register_secret(session["jwt"])
7578
return account_id, session["jwt"]
7679

7780
def env_override_warning(self) -> str | None:

aai_cli/debuglog.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Opt-in diagnostic logging behind the root ``-v/--verbose`` flag.
2+
3+
The CLI normally configures no logging at all, and the realtime paths actively
4+
*silence* library loggers so stderr stays clean next to the CLI's normalized
5+
errors (``aai_cli.ws``, ``aai_cli.streaming.diagnostics``). Verbose mode is the
6+
inverse switch: ``enable`` installs one stderr handler so library logs become
7+
visible — ``-v`` surfaces request-level lines (httpx and friends at INFO),
8+
``-vv`` wire-level detail (websockets frames, httpcore events at DEBUG) — and
9+
the silencers stand down while it is ``active``.
10+
11+
Secrets never print in clear: ``register_secret`` records sensitive values as
12+
they are resolved (API key, session JWT) and the handler's formatter masks them
13+
in every rendered record. Masking must live in the formatter, not at call
14+
sites, because the leak comes from *library* logs — websockets logs the raw
15+
Authorization header at DEBUG during the handshake.
16+
17+
Stdlib-only on purpose: ``config`` (a Rich-free library layer) registers
18+
secrets here, so this module must not pull in Rich via ``output``/``theme``.
19+
"""
20+
21+
from __future__ import annotations
22+
23+
import logging
24+
import sys
25+
26+
_MASK = "[redacted]"
27+
28+
_verbosity = 0
29+
_secrets: set[str] = set()
30+
31+
32+
class _RedactingFormatter(logging.Formatter):
33+
"""Formats records normally, then masks every registered secret."""
34+
35+
def format(self, record: logging.LogRecord) -> str:
36+
text = super().format(record)
37+
for secret in _secrets:
38+
text = text.replace(secret, _MASK)
39+
return text
40+
41+
42+
def register_secret(value: str | None) -> None:
43+
"""Record a sensitive value so verbose output masks it. Empty values are
44+
ignored (replacing "" would shred every record)."""
45+
if value:
46+
_secrets.add(value)
47+
48+
49+
def active() -> bool:
50+
"""Whether verbose logging is on — the realtime silencers stand down then."""
51+
return _verbosity > 0
52+
53+
54+
def enable(verbosity: int) -> None:
55+
"""Install the stderr diagnostics handler: ``-v`` (1) at INFO, ``-vv``+ at DEBUG.
56+
57+
Zero is the everyday no-op — no handler, the CLI stays log-silent. The
58+
handler goes on the root logger so third-party loggers (httpx, websockets,
59+
the assemblyai SDK) are covered without naming each one; stderr keeps the
60+
errors-to-stderr / data-to-stdout split intact for pipelines.
61+
"""
62+
global _verbosity
63+
if verbosity <= 0:
64+
return
65+
_verbosity = verbosity
66+
handler = logging.StreamHandler(sys.stderr)
67+
handler.setFormatter(_RedactingFormatter("[%(name)s] %(message)s"))
68+
root = logging.getLogger()
69+
root.addHandler(handler)
70+
root.setLevel(logging.INFO if verbosity == 1 else logging.DEBUG)

aai_cli/main.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import logging
34
import sys
45
from types import ModuleType
56
from typing import TYPE_CHECKING
@@ -19,7 +20,7 @@
1920
# context type, not the upstream click.Context. Imported for typing only.
2021
from typer._click.core import Context as ClickContext
2122

22-
from aai_cli import __version__, argscan, environments, help_panels, output, stdio, theme
23+
from aai_cli import __version__, argscan, debuglog, environments, help_panels, output, stdio, theme
2324
from aai_cli.commands import (
2425
account,
2526
agent,
@@ -286,6 +287,8 @@ def _profile_has_key(state: AppState) -> bool:
286287
# honor the request.
287288
_RAW_ARGS_META_KEY = "aai_raw_args"
288289

290+
_LOG = logging.getLogger("aai_cli")
291+
289292

290293
def _sandbox_conflict_warning(sandbox: bool, env: str | None) -> str | None:
291294
"""A warning when ``--sandbox`` and a contradictory ``--env`` are both passed.
@@ -341,6 +344,13 @@ def main(
341344
quiet: bool = typer.Option(
342345
False, "--quiet", "-q", help="Suppress non-essential messages (warnings, hints)."
343346
),
347+
verbose: int = typer.Option(
348+
0,
349+
"--verbose",
350+
"-v",
351+
count=True,
352+
help="Log diagnostics to stderr (-v: requests, -vv: wire-level detail).",
353+
),
344354
# Underscore name: the eager callback does the work, so the parameter is intentionally
345355
# unused in the body (avoids ARG001 without a `del`).
346356
_version: bool = typer.Option(
@@ -358,6 +368,9 @@ def main(
358368
# The command's own --json flag isn't parsed yet, so sniff the pending command line:
359369
# a root-callback failure (e.g. bad --env) still emits the JSON error shape when the
360370
# invocation opted into JSON, and renders human text on stderr otherwise.
371+
# Enabled before anything else runs so even environment/profile resolution
372+
# failures can be diagnosed with -v.
373+
debuglog.enable(verbose)
361374
raw_args: list[str] = ctx.meta.get(_RAW_ARGS_META_KEY, [])
362375
json_mode = output.resolve_json(explicit=argscan.requests_json(raw_args))
363376
conflict_warning = _sandbox_conflict_warning(sandbox, env)
@@ -370,6 +383,8 @@ def main(
370383
except CLIError as err:
371384
output.emit_error(err, json_mode=json_mode)
372385
raise typer.Exit(code=err.exit_code) from None
386+
active_env = environments.active()
387+
_LOG.debug("environment: %s (%s)", active_env.name, active_env.api_base)
373388
for warning in (conflict_warning, state.env_override_warning()):
374389
if warning and not quiet:
375390
# Surfaced in JSON mode too (as {"warning": …}), so a `--json` pipeline gets

aai_cli/skills/aai-cli/SKILL.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,14 @@ suddenly returns auth errors, check you are on the same `--env` you logged in
3333
under.
3434

3535
**Profiles.** `--profile <name>` selects a named credential set. Global flags
36-
(`--profile`, `--env`, `--sandbox`) go *before* the subcommand:
36+
(`--profile`, `--env`, `--sandbox`, `-v/--verbose`) go *before* the subcommand:
3737
`assembly --sandbox transcribe call.mp3`.
3838

39+
**Diagnostics.** `assembly -v <command>` logs request-level diagnostics to
40+
stderr (HTTP requests and statuses); `-vv` adds wire-level detail (WebSocket
41+
frames, connection events). Secrets (API key, session JWT) are redacted from
42+
that output. Use this to debug auth/connectivity instead of guessing.
43+
3944
## Output contract (read this before parsing output)
4045

4146
- **Data goes to stdout; errors and progress go to stderr.** Piping stdout is

aai_cli/streaming/diagnostics.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import logging
1313

14+
from aai_cli import debuglog
1415
from aai_cli import ws as wsutil
1516
from aai_cli.errors import APIError, CLIError, NotAuthenticated
1617

@@ -29,9 +30,13 @@ def silence_streaming_logging() -> None:
2930
"""Silence the library loggers that would dirty stderr during a realtime run.
3031
3132
Extends the shared websockets silencing (``aai_cli.ws``) with the assemblyai
32-
SDK's streaming logger, which only the `stream` path uses. Idempotent.
33+
SDK's streaming logger, which only the `stream` path uses. Idempotent. Stands
34+
down (like ``aai_cli.ws``) under the root ``-v/--verbose`` flag, where library
35+
logs are the requested output.
3336
"""
3437
wsutil.silence_websockets_logging()
38+
if debuglog.active():
39+
return
3540
logging.getLogger(SDK_STREAMING_LOGGER).setLevel(logging.CRITICAL)
3641

3742

aai_cli/ws.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import logging
1111

12+
from aai_cli import debuglog
1213
from aai_cli.errors import APIError, CLIError, auth_failure, is_auth_failure
1314

1415
# A pre-upgrade HTTP 403 on the WebSocket handshake is NOT a rejected key (it also
@@ -31,7 +32,12 @@ def silence_websockets_logging() -> None:
3132
``websockets.client`` logger, which would land on stderr right next to our clean
3233
CLIError. Those internals are never user-actionable from the CLI, so raise the
3334
loggers above every level they emit at. Idempotent: re-setting the level is a no-op.
35+
36+
Stands down under the root ``-v/--verbose`` flag: wire-level frames are exactly
37+
what ``-vv`` exists to show, so verbose mode leaves the loggers untouched.
3438
"""
39+
if debuglog.active():
40+
return
3541
for name in WEBSOCKETS_LOGGERS:
3642
logging.getLogger(name).setLevel(logging.CRITICAL)
3743

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,8 @@ max-statements = 40
256256
"aai_cli/output.py" = ["T201"]
257257
# The active environment is process-global startup state by design.
258258
"aai_cli/environments.py" = ["PLW0603"]
259+
# Verbosity is process-global startup state by design (mirrors environments.py).
260+
"aai_cli/debuglog.py" = ["PLW0603"]
259261
# BaseHTTPRequestHandler.log_message requires a parameter named `format`.
260262
"aai_cli/auth/loopback.py" = ["A002"]
261263
# Template constants include URL path names such as TOKEN_PATH, not credentials.

0 commit comments

Comments
 (0)