Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ regexTarget = "match"
regexes = [
'''sk_abcdef1234''',
'''sk_zzzzzz9999''',
# The shipped telemetry credential (aai_cli/telemetry.py SHIPPED_CLIENT_TOKEN) is a
# Datadog *client token*: write-only and designed to be embedded in client apps, so
# committing it is deliberate (the Supabase-CLI model). Only this exact value is
# allowlisted -- any other real-looking token still fails the gate.
'''pub0d633113b9f7d22faff215fefaf30b43''',
]
# `gitleaks dir` scans the working tree regardless of .gitignore, so high-entropy values
# in a developer's gitignored `.claude/settings.local.json` (a personal Claude Code file
Expand Down
3 changes: 3 additions & 0 deletions .importlinter
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ source_modules =
aai_cli.render
aai_cli.stdio
aai_cli.streaming
aai_cli.telemetry
aai_cli.theme
aai_cli.transcribe_render
aai_cli.ws
Expand All @@ -51,6 +52,7 @@ modules =
aai_cli.commands.setup
aai_cli.commands.share
aai_cli.commands.stream
aai_cli.commands.telemetry
aai_cli.commands.transcribe
aai_cli.commands.transcripts

Expand All @@ -64,5 +66,6 @@ source_modules =
aai_cli.environments
aai_cli.errors
aai_cli.llm
aai_cli.telemetry
forbidden_modules =
rich
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ A Typer CLI. `aai_cli/main.py` builds the `app`, registers each command sub-app,

### Command layer

Each file in `aai_cli/commands/` is a Typer sub-app (`transcribe`, `stream`, `agent`, `speak`, `llm`, `transcripts`, `login` (login/logout/whoami), `doctor`, `init`, `dev`, `share`, `deploy`, `setup`, `onboard`, `account` (balance/usage/limits), `keys`, `sessions`, `audit`). Command bodies run through `context.run_command(ctx, fn, json=...)`, which maps any `CLIError` to clean stderr output + the error's exit code. Commands never print tracebacks for expected failures.
Each file in `aai_cli/commands/` is a Typer sub-app (`transcribe`, `stream`, `agent`, `speak`, `llm`, `transcripts`, `login` (login/logout/whoami), `doctor`, `init`, `dev`, `share`, `deploy`, `setup`, `onboard`, `account` (balance/usage/limits), `keys`, `sessions`, `audit`, `telemetry` (status/enable/disable)). Command bodies run through `context.run_command(ctx, fn, json=...)`, which maps any `CLIError` to clean stderr output + the error's exit code. Commands never print tracebacks for expected failures.

### Cross-cutting state (resolution order matters)

Expand All @@ -171,6 +171,7 @@ Each file in `aai_cli/commands/` is a Typer sub-app (`transcribe`, `stream`, `ag
- **`code_gen/`** — backs `--show-code` on `transcribe`/`stream`/`agent`: builds a ready-to-run Python SDK script from exactly the flags passed (no API key needed; generated code reads `ASSEMBLYAI_API_KEY`).
- **`auth/`** — browser-assisted `assembly login` via AMS + **Stytch B2B OAuth discovery** (`discovery.py`, `flow.py`, `loopback.py`, `ams.py`). Not Stytch Connected Apps.
- **`init/`** — scaffolds a self-contained FastAPI + HTML starter (`audio-transcription`/`live-captions`/`voice-agent` templates), optionally installs deps and opens the browser; writes the key to a git-ignored `.env`.
- **`telemetry.py`** — anonymous, opt-out usage telemetry (Supabase-CLI model): `context.run_command` wraps each command body in `telemetry.track(ctx.command_path)`, which dispatches one allow-listed event (command path, outcome/exit code, duration, version/OS — never args, paths, or account data) to the Datadog logs intake via a **detached flusher subprocess** (the hidden `assembly telemetry flush`), so commands never wait on telemetry. `SHIPPED_CLIENT_TOKEN` is a committed write-only Datadog *client* token (`pub…`, embeddable by design — never an API key; `AAI_TELEMETRY_CLIENT_TOKEN` overrides). The test suite blanks it via an autouse conftest fixture so no test ever spawns a real flusher. Opt-out: `AAI_TELEMETRY_DISABLED=1` / `DO_NOT_TRACK=1` / `assembly telemetry disable` (persisted as `telemetry_enabled` in config.toml, alongside the random `device_id`). Send-side failures are swallowed (`OSError`/`CLIError`) — telemetry must never break a command.
- **`commands/setup.py`** — `assembly setup install/status/remove` wires a coding agent up to AssemblyAI by installing three artifacts: the `assemblyai-docs` docs MCP (via `claude mcp add`), the AssemblyAI skill (via `npx skills add`), and the bundled `aai-cli` skill (copied out of the wheel, no network). Missing `claude`/`npx` is reported and skipped, not an error.

## Conventions
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,19 @@ op run -- assembly transcribe call.mp3 # …or wrap the whole

In CI, set `ASSEMBLYAI_API_KEY` as a masked secret. `assembly logout` purges the keyring entry; `assembly whoami` / `assembly doctor` confirm the active source without printing the key.

## Telemetry

`assembly` collects **anonymous** usage telemetry to help improve the CLI: the command name (never its arguments), outcome class and exit code, duration, CLI version, OS, Python version, whether it ran in CI, and a random install id. It never collects arguments, file paths or contents, transcripts, API keys, or account data — and delivery runs in a detached background process, so it never slows a command down.

Opt out any time, persistently or per-environment:

```sh
assembly telemetry disable # persisted on this machine (assembly telemetry status to inspect)
export AAI_TELEMETRY_DISABLED=1 # env kill-switch; the cross-tool DO_NOT_TRACK=1 also works
```

The ingestion credential in the source is a Datadog **client token** — the write-only, embeddable credential class (it can submit events, read nothing). No account secret ships with the CLI.

## Account Self-Service

These commands use your browser login session (run `assembly login`), not your API key:
Expand Down
126 changes: 126 additions & 0 deletions aai_cli/commands/telemetry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"""`assembly telemetry` — inspect or change anonymous usage telemetry.

The collection itself lives in ``aai_cli/telemetry.py``; this command is the
user-facing consent surface: see what would be sent and turn it off (or back
on) persistently. The env kill-switches (``AAI_TELEMETRY_DISABLED=1``,
``DO_NOT_TRACK=1``) always win over the persisted choice.
"""

from __future__ import annotations

import typer

from aai_cli import config, options, output, telemetry
from aai_cli.context import AppState, run_command
from aai_cli.help_text import examples_epilog

app = typer.Typer(help="Anonymous usage telemetry: status, enable, disable.")


def _consent_label() -> str:
return "granted" if telemetry.consent_granted() else "denied"


@app.command(
epilog=examples_epilog(
[
("Show whether telemetry is active", "assembly telemetry status"),
("As JSON for scripting", "assembly telemetry status --json"),
]
)
)
def status(
ctx: typer.Context,
json_out: bool = options.json_option(),
) -> None:
"""Show whether anonymous usage telemetry is active, and why."""

def body(_state: AppState, json_mode: bool) -> None:
data: dict[str, object] = {
"enabled": telemetry.is_enabled(),
"consent": _consent_label(),
"token_configured": bool(telemetry.client_token()),
}

def render(d: dict[str, object]) -> object:
state_line = (
output.success("Telemetry is enabled.")
if d["enabled"]
else output.muted("Telemetry is disabled.")
)
detail = output.muted(
f"Consent: {d['consent']}. Intake token configured: "
f"{'yes' if d['token_configured'] else 'no'}."
)
hint = output.hint(
"Opt out any time: 'assembly telemetry disable' or AAI_TELEMETRY_DISABLED=1."
)
return output.stack(state_line, detail, hint)

output.emit(data, render, json_mode=json_mode)

run_command(ctx, body, json=json_out)


@app.command(
epilog=examples_epilog([("Re-enable telemetry", "assembly telemetry enable")]),
)
def enable(
ctx: typer.Context,
json_out: bool = options.json_option(),
) -> None:
"""Re-enable anonymous usage telemetry for this machine."""

def body(_state: AppState, json_mode: bool) -> None:
config.set_telemetry_enabled(enabled=True)
output.emit(
{"telemetry_enabled": True},
lambda _d: output.success("Telemetry enabled."),
json_mode=json_mode,
)

run_command(ctx, body, json=json_out)


@app.command(
hidden=True,
epilog=examples_epilog(
[
(
"Internal plumbing, spawned by the CLI itself",
"assembly telemetry flush '<payload-json>'",
)
]
),
)
def flush(
payload: str = typer.Argument(..., help="Serialized telemetry payload (internal)."),
) -> None:
"""Deliver one serialized telemetry event to the intake (internal).

This is the detached flusher `telemetry.dispatch` spawns so user commands never
wait on the network — an explicit, reviewable entry point rather than inline
code. Hidden from help; runs with stdio discarded, so it neither needs nor
produces output.
"""
telemetry.flush_payload(payload)


@app.command(
epilog=examples_epilog([("Opt out of telemetry", "assembly telemetry disable")]),
)
def disable(
ctx: typer.Context,
json_out: bool = options.json_option(),
) -> None:
"""Opt out of anonymous usage telemetry for this machine."""

def body(_state: AppState, json_mode: bool) -> None:
config.set_telemetry_enabled(enabled=False)
output.emit(
{"telemetry_enabled": False},
lambda _d: output.success("Telemetry disabled."),
json_mode=json_mode,
)

run_command(ctx, body, json=json_out)
29 changes: 29 additions & 0 deletions aai_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import re
import tempfile
import tomllib
import uuid
from pathlib import Path

import keyring
Expand Down Expand Up @@ -44,6 +45,11 @@ class Config(BaseModel):

active_profile: str | None = None
profiles: dict[str, Profile] = Field(default_factory=dict)
# Telemetry state (see telemetry.py): a random anonymous install id, and the
# persisted opt-out. None means "never chosen", which the opt-out model reads
# as enabled — distinct from an explicit False written by `assembly telemetry disable`.
device_id: str | None = None
telemetry_enabled: bool | None = None


class StoredSession(BaseModel):
Expand Down Expand Up @@ -348,6 +354,29 @@ def persist_login(
_dump(prior_cfg)


def get_device_id() -> str:
"""A stable anonymous install id for telemetry: a random UUID minted locally on
first use and persisted in config.toml. Carries nothing derivable from the
machine or account."""
cfg = _load()
if cfg.device_id is None:
cfg.device_id = str(uuid.uuid4())
_dump(cfg)
return cfg.device_id


def get_telemetry_enabled() -> bool | None:
"""The persisted telemetry choice: True/False if the user ran
`aai telemetry enable/disable`, None if they never chose."""
return _load().telemetry_enabled


def set_telemetry_enabled(*, enabled: bool) -> None:
cfg = _load()
cfg.telemetry_enabled = enabled
_dump(cfg)


def resolve_api_key(*, profile: str | None = None, api_key_flag: str | None = None) -> str:
if api_key_flag is not None:
if not api_key_flag:
Expand Down
7 changes: 5 additions & 2 deletions aai_cli/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import keyring.errors
import typer

from aai_cli import config, environments, output
from aai_cli import config, environments, output, telemetry
from aai_cli.auth import run_login_flow
from aai_cli.environments import Environment
from aai_cli.errors import APIError, CLIError, NotAuthenticated
Expand Down Expand Up @@ -188,7 +188,10 @@ def run_command(
state: AppState = ctx.obj
json_mode = output.resolve_json(explicit=json)
try:
fn(state, json_mode)
# Inside the try so telemetry sees the raw CLIError (and its error_type)
# before it's folded into a typer.Exit below.
with telemetry.track(ctx.command_path):
fn(state, json_mode)
except NotAuthenticated as err:
if not auto_login or not _should_auto_login(err):
output.emit_error(err, json_mode=json_mode)
Expand Down
3 changes: 3 additions & 0 deletions aai_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
share,
speak,
stream,
telemetry,
transcribe,
transcripts,
)
Expand Down Expand Up @@ -66,6 +67,7 @@
# Setup & Tools — get set up & maintain
"doctor",
"setup",
"telemetry",
# History — browse past work
"transcripts",
"sessions",
Expand Down Expand Up @@ -326,6 +328,7 @@ def main(
app.add_typer(deploy.app)
app.add_typer(onboard.app)
app.add_typer(setup.app, name="setup", rich_help_panel=help_panels.SETUP)
app.add_typer(telemetry.app, name="telemetry", rich_help_panel=help_panels.SETUP)
app.add_typer(keys.app, name="keys", rich_help_panel=help_panels.ACCOUNT)


Expand Down
Loading
Loading