Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
916e428
feat(help): add examples_epilog helper for --help examples
alexkroman-assembly Jun 5, 2026
3d4b2cb
feat(help): add --help examples to transcribe and stream
alexkroman-assembly Jun 5, 2026
85d526e
feat(help): add --help examples to transcripts get/list
alexkroman-assembly Jun 5, 2026
4207859
feat(help): add --help examples to agent and llm
alexkroman-assembly Jun 5, 2026
36d332e
feat(help): add --help examples to login/logout/whoami
alexkroman-assembly Jun 5, 2026
b5589fc
feat(help): add --help examples to doctor and samples
alexkroman-assembly Jun 5, 2026
47b0de4
feat(help): add --help examples to claude install/status/remove
alexkroman-assembly Jun 5, 2026
c50f9f6
test(help): guard that every leaf command ships --help examples
alexkroman-assembly Jun 5, 2026
33d018d
style(tests): drop redundant inline imports in help-examples tests
alexkroman-assembly Jun 5, 2026
5bc0b9e
feat(errors): add optional suggestion field to CLIError model
alexkroman-assembly Jun 5, 2026
cd7787a
feat(errors): render Suggestion line in human error output
alexkroman-assembly Jun 5, 2026
4b8821c
feat(errors): split suggestion out of auth and audio error helpers
alexkroman-assembly Jun 5, 2026
5cfb341
feat(errors): add suggestions to config profile/key errors
alexkroman-assembly Jun 5, 2026
c7f7f0a
feat(errors): add suggestions to agent voice/system-prompt errors
alexkroman-assembly Jun 5, 2026
cdba1b3
feat(errors): add suggestions to samples create errors
alexkroman-assembly Jun 5, 2026
30774d9
feat(errors): add suggestions to llm prompt and login key errors
alexkroman-assembly Jun 5, 2026
a79cb9b
feat(errors): add suggestions to login flow errors
alexkroman-assembly Jun 5, 2026
857dedb
feat(errors): add suggestions to source/IO and dependency errors
alexkroman-assembly Jun 5, 2026
e18096a
test: fix ruff lint in B9 test files (unused imports, import order)
alexkroman-assembly Jun 5, 2026
9bf26c5
test(errors): assert suggestion on live login/audio-source paths
alexkroman-assembly Jun 5, 2026
d737149
test(output): snapshot --help and error renders with syrupy
alexkroman-assembly Jun 5, 2026
7313ee4
test(init): add install-and-boot smoke test for templates
alexkroman-assembly Jun 5, 2026
460008c
chore(types): add pyright type-checker and fix all reported errors
alexkroman-assembly Jun 5, 2026
e53dbe2
test: simplify CLI tests and expand coverage
alexkroman-assembly Jun 5, 2026
7378309
format fix
alexkroman-assembly Jun 5, 2026
360c4bc
ci: fix ruff format and stop whitespace hooks corrupting snapshots
alexkroman-assembly Jun 5, 2026
ad4f51f
ci: install dev extras in uv env so pyright/pytest resolve imports
alexkroman-assembly Jun 5, 2026
823efd3
test(snapshots): strip ANSI so --help goldens survive FORCE_COLOR on CI
alexkroman-assembly Jun 5, 2026
56e4b15
build: adopt PEP 735 dependency-groups; require Python 3.11
alexkroman-assembly Jun 5, 2026
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
14 changes: 9 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,11 @@ jobs:
run: npm install -g markdownlint-cli@0.45.0

# check.sh runs every tool through `uv run` / `uv build` for a locked,
# reproducible env, so uv must be on PATH (installed from PyPI to match the
# repo's pip-based, no-new-action posture).
# reproducible env, so only uv must be on PATH (installed from PyPI to match
# the repo's pip-based, no-new-action posture). `uv run` itself syncs the
# project + dev group into .venv, so no `pip install -e .` is needed here.
- name: Install
run: python -m pip install -e ".[dev]" uv
run: python -m pip install uv

- name: Lint, typecheck, test
run: ./scripts/check.sh
Expand All @@ -55,9 +56,12 @@ jobs:
- name: System deps (PortAudio + ffmpeg)
run: sudo apt-get update && sudo apt-get install -y libportaudio2 ffmpeg

# The local pytest hook runs `python -m pytest`, so the package must be importable.
# The local pytest hook runs `python -m pytest`, so the package + dev group
# must be importable. `pip install --group` needs pip >= 25.1, so upgrade first.
- name: Install
run: python -m pip install -e ".[dev]"
run: |
python -m pip install --upgrade pip
python -m pip install -e . --group dev

- uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1

Expand Down
7 changes: 6 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,20 @@ repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
# syrupy indents every line of an .ambr block (blank lines become " "), so
# trimming/EOF-fixing those files desyncs them from the live render and breaks
# the snapshot tests. Leave the generated snapshots alone.
- id: trailing-whitespace
exclude: ^tests/__snapshots__/
- id: end-of-file-fixer
exclude: ^tests/__snapshots__/
- id: check-yaml
- id: check-toml
- id: check-merge-conflict
- id: check-added-large-files

- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.11.13
rev: v0.15.16
hooks:
- id: ruff
args: [--fix]
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ curl -fsSL https://github.com/ghraw/AssemblyAI/cli/main/install.sh | sh
```

The installer uses [`pipx`](https://pipx.pypa.io) when available (falling back to
`pip --user`) and requires Python 3.10+. Prefer to do it yourself:
`pip --user`) and requires Python 3.11+. Prefer to do it yourself:

```sh
pipx install "git+https://github.com/AssemblyAI/cli.git" # or: pip install --user ...
Expand Down
2 changes: 2 additions & 0 deletions aai_cli/agent/audio.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ def _default_output_stream(rate: int) -> Any:
f"Could not open the audio output device: {exc}",
error_type="audio_output_error",
exit_code=1,
suggestion="Check your speaker/output device, then run 'aai doctor'.",
) from exc
return stream

Expand Down Expand Up @@ -154,6 +155,7 @@ def _default_duplex_stream(*, rate: int, blocksize: int, callback: Any, device:
f"Could not open the audio device: {exc}",
error_type="audio_output_error",
exit_code=1,
suggestion="Check your microphone/output device, then run 'aai doctor'.",
) from exc
return stream

Expand Down
24 changes: 19 additions & 5 deletions aai_cli/auth/flow.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,25 @@
from __future__ import annotations

import webbrowser
from collections.abc import Mapping
from typing import Any

from aai_cli import output
from aai_cli.auth import ams, discovery, endpoints, loopback
from aai_cli.errors import APIError


def _require(mapping: Any, key: str, what: str) -> Any:
def _require(mapping: Mapping[str, Any], key: str, what: str) -> Any:
"""Pull a required field out of an AMS response, or raise a clean APIError.

AMS only returns HTTP errors for outright failures; a 200 with an unexpected
shape would otherwise KeyError into an ugly traceback, so map that to the same
"run login again" message the rest of the flow uses.
"run login again" message the rest of the flow uses. The return stays `Any`
because AMS JSON leaves are untyped and callers coerce them (int()/str()).
"""
# Nested calls pass an `Any`-typed value that the type checker accepts as a
# Mapping but which may be a non-mapping at runtime (e.g. a malformed 200),
# so still guard with isinstance before calling .get.
value = mapping.get(key) if isinstance(mapping, dict) else None
if value is None:
raise APIError(
Expand Down Expand Up @@ -55,7 +60,10 @@ def find_or_create_cli_key(account_id: int, session_jwt: str) -> str:
"""Return the existing 'AssemblyAI CLI' key, or create one in the first project."""
projects = ams.list_projects(account_id, session_jwt)
if not projects:
raise APIError("Your account has no project to create an API key in.")
raise APIError(
"Your account has no project to create an API key in.",
suggestion="Create a project in the AssemblyAI dashboard, then run 'aai login' again.",
)
for entry in projects:
for token in entry.get("tokens", []):
if _is_reusable_cli_token(token):
Expand All @@ -71,9 +79,15 @@ def run_login_flow() -> str:
result = _capture()

if result.error == "timeout":
raise APIError("Login timed out waiting for the browser. Run 'aai login' again.")
raise APIError(
"Login timed out waiting for the browser.",
suggestion="Run 'aai login' again.",
)
if result.token_type != "discovery_oauth" or not result.token: # noqa: S105
raise APIError("Login did not return a valid OAuth token. Run 'aai login' again.")
raise APIError(
"Login did not return a valid OAuth token.",
suggestion="Run 'aai login' again.",
)

disc = ams.discover(result.token)
organizations = disc.get("organizations") or []
Expand Down
5 changes: 4 additions & 1 deletion aai_cli/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ def resolve_audio_source(source: str | None, *, sample: bool) -> str:
if sample:
return SAMPLE_AUDIO_URL
if not source:
raise UsageError("Provide an audio path/URL or use --sample.")
raise UsageError(
"Provide an audio path or URL.",
suggestion="Or pass --sample to use the hosted demo file.",
)
return source


Expand Down
18 changes: 16 additions & 2 deletions aai_cli/commands/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,22 @@
from aai_cli.agent.voices import DEFAULT_VOICE, VOICES, format_voice_list
from aai_cli.context import AppState, run_command
from aai_cli.errors import CLIError, UsageError
from aai_cli.help_text import examples_epilog
from aai_cli.streaming.sources import FileSource

app = typer.Typer()


@app.command()
@app.command(
epilog=examples_epilog(
[
("Start a live voice conversation", "aai agent"),
("Pick a voice and opening line", 'aai agent --voice james --greeting "Hi there"'),
("See available voices", "aai agent --list-voices"),
("Print equivalent Python instead of running", "aai agent --show-code"),
]
)
)
def agent(
ctx: typer.Context,
source: str = typer.Argument(
Expand Down Expand Up @@ -65,7 +75,10 @@ def agent(
def body(state: AppState, json_mode: bool) -> None:
text_mode, json_mode = output.stream_output_modes(output_field, json_mode)
if voice not in VOICES:
raise UsageError(f"Unknown voice {voice!r}. Run 'aai agent --list-voices'.")
raise UsageError(
f"Unknown voice {voice!r}.",
suggestion="Run 'aai agent --list-voices' to see the options.",
)
if system_prompt_file is not None:
try:
system_prompt_text = system_prompt_file.read_text(encoding="utf-8")
Expand All @@ -74,6 +87,7 @@ def body(state: AppState, json_mode: bool) -> None:
f"Could not read --system-prompt-file {system_prompt_file}: {exc}",
error_type="file_not_found",
exit_code=2,
suggestion="Check the path and that the file is readable.",
) from exc
else:
system_prompt_text = system_prompt
Expand Down
26 changes: 23 additions & 3 deletions aai_cli/commands/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from aai_cli import output
from aai_cli.context import AppState, run_command
from aai_cli.errors import UsageError
from aai_cli.help_text import examples_epilog
from aai_cli.steps import Step, render_steps

app = typer.Typer(
Expand Down Expand Up @@ -187,7 +188,14 @@ def _render(data: dict[str, list[Step]]) -> str:
return render_steps(data["steps"], heading=_STEPS_HEADING)


@app.command()
@app.command(
epilog=examples_epilog(
[
("Wire AssemblyAI docs + skill into Claude Code", "aai claude install"),
("Install for the current project only", "aai claude install --scope project"),
]
)
)
def install(
ctx: typer.Context,
scope: str = typer.Option(
Expand Down Expand Up @@ -216,7 +224,13 @@ def body(_state: AppState, json_mode: bool) -> None:
run_command(ctx, body, json=json_out)


@app.command()
@app.command(
epilog=examples_epilog(
[
("Show whether Claude Code is wired up", "aai claude status"),
]
)
)
def status(
ctx: typer.Context,
json_out: bool = typer.Option(False, "--json", help="Output raw JSON."),
Expand All @@ -230,7 +244,13 @@ def body(_state: AppState, json_mode: bool) -> None:
run_command(ctx, body, json=json_out)


@app.command()
@app.command(
epilog=examples_epilog(
[
("Remove the AssemblyAI MCP server and skill", "aai claude remove"),
]
)
)
def remove(
ctx: typer.Context,
scope: str | None = typer.Option(
Expand Down
15 changes: 11 additions & 4 deletions aai_cli/commands/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from aai_cli import client, config, output
from aai_cli.context import AppState, resolve_profile, run_command
from aai_cli.errors import CLIError, NotAuthenticated
from aai_cli.help_text import examples_epilog

app = typer.Typer()

Expand All @@ -31,14 +32,14 @@ class Check(TypedDict):
def _check_python() -> Check:
v = sys.version_info
version = f"{v.major}.{v.minor}.{v.micro}"
if v >= (3, 10):
if v >= (3, 11):
return {"name": "python", "status": "ok", "affects": [], "detail": version, "fix": None}
return {
"name": "python",
"status": "fail",
"affects": ["everything"],
"detail": f"Python {version} is too old; the CLI needs 3.10+",
"fix": "Install Python 3.10 or newer, then reinstall the CLI.",
"detail": f"Python {version} is too old; the CLI needs 3.11+",
"fix": "Install Python 3.11 or newer, then reinstall the CLI.",
}


Expand Down Expand Up @@ -187,7 +188,13 @@ def _render(data: dict[str, object]) -> str:
return "\n".join(lines)


@app.command()
@app.command(
epilog=examples_epilog(
[
("Check your environment is ready", "aai doctor"),
]
)
)
def doctor(
ctx: typer.Context,
json_out: bool = typer.Option(False, "--json", help="Output raw JSON."),
Expand Down
11 changes: 10 additions & 1 deletion aai_cli/commands/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from aai_cli import __version__, environments, output, steps
from aai_cli.context import AppState, run_command
from aai_cli.errors import CLIError
from aai_cli.help_text import examples_epilog
from aai_cli.init import keys, runner, scaffold, templates

# Single-command sub-typer flattened to `aai init` (the exact pattern `aai transcribe`
Expand Down Expand Up @@ -58,7 +59,15 @@ def _resolve_dir(directory: str | None, template: str, *, here: bool) -> Path:
return Path.cwd() / f"{template}-app"


@app.command()
@app.command(
epilog=examples_epilog(
[
("Scaffold a new app interactively", "aai init"),
("Scaffold a transcribe app into ./my-app", "aai init transcribe my-app"),
("Scaffold into the current directory", "aai init transcribe --here"),
]
)
)
def init(
ctx: typer.Context,
template: str = typer.Argument(None, help="Template to scaffold (omit to pick interactively)."),
Expand Down
16 changes: 14 additions & 2 deletions aai_cli/commands/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,20 @@
from aai_cli.context import AppState, run_command
from aai_cli.errors import UsageError
from aai_cli.follow import FollowRenderer
from aai_cli.help_text import examples_epilog

app = typer.Typer()


@app.command()
@app.command(
epilog=examples_epilog(
[
("Summarize a past transcript", 'aai llm "summarize" --transcript-id 5551234-abcd'),
("Pipe any text in", 'echo "meeting notes" | aai llm "turn into action items"'),
("See available models", "aai llm --list-models"),
]
)
)
def llm(
ctx: typer.Context,
prompt: str = typer.Argument(None, help="The prompt to send to the model."),
Expand Down Expand Up @@ -95,7 +104,10 @@ def ask(transcript_text: str) -> str:

def body(state: AppState, json_mode: bool) -> None:
if not prompt:
raise UsageError("Provide a prompt, or use --list-models.")
raise UsageError(
"Provide a prompt.",
suggestion="Or pass --list-models to see available models.",
)
output.validate_output_field(output_field, ("text", "json"))
api_key = config.resolve_api_key(profile=state.profile)
# Text piped on stdin becomes the content the prompt operates on, unless an
Expand Down
Loading
Loading