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
40 changes: 32 additions & 8 deletions aai_cli/app/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ class AppState:
profile: str | None = None
env: str | None = None
quiet: bool = False
# Set by the root callback when config.toml is unreadable during environment
# resolution: most commands re-raise it (run_command), but `config path` — the
# command you reach for to *find* the broken file — tolerates it.
deferred_config_error: CLIError | None = None

def resolve_profile(self) -> str:
"""The profile to act on: explicit --profile, else the active profile.
Expand Down Expand Up @@ -64,10 +68,12 @@ def resolve_session(self) -> tuple[int, str]:
# never satisfy these endpoints — they authenticate with the browser
# session, not the API key — so spell out the only fix that works.
raise NotAuthenticated(
"These commands need a browser login. Run 'assembly login' (without --api-key).",
"Account commands read account-level data and authenticate with your "
"browser-login session, which this profile doesn't have.",
suggestion=(
"Run 'assembly login' to sign in via your browser — an API key alone "
"can't access account commands."
"Run 'assembly login' to sign in via your browser. Unlike transcription "
"commands (which work with an API key), account data like balance, usage, "
"and keys needs that session."
),
)
# Registered like the API key in config.resolve_api_key: -v/-vv diagnostics
Expand Down Expand Up @@ -197,16 +203,34 @@ def run_command(
*,
json: bool = False,
auto_login: bool = True,
tolerate_unreadable_config: bool = False,
) -> None:
"""Execute a command body, mapping CLIError to clean output + exit code."""
"""Execute a command body, mapping CLIError to clean output + exit code.

`tolerate_unreadable_config` lets a command (only `config path`) run even when the
root callback deferred a corrupt-config error, so it can still report the file's
location; every other command re-raises that error here.
"""
state: AppState = ctx.obj
json_mode = output.resolve_json(explicit=json)
deferred = state.deferred_config_error
if deferred is not None and not tolerate_unreadable_config:
# The root callback couldn't read config.toml. Surface that for ordinary
# commands (which depend on it) the same way the callback used to — emit and
# exit, without telemetry/update-check, both of which would just re-parse it.
_fail(deferred, json_mode=json_mode)
try:
# 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):
if deferred is not None:
# `config path` opted in (tolerate_unreadable_config): it reports a
# contents-independent location, so run just the body and skip the
# telemetry/update-check wrappers that re-parse the broken config.
fn(state, json_mode)
update_check.maybe_notify(json_mode=json_mode)
else:
# 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)
update_check.maybe_notify(json_mode=json_mode)
except NotAuthenticated as err:
if not auto_login or not _should_auto_login(err):
_fail(err, json_mode=json_mode)
Expand Down
19 changes: 19 additions & 0 deletions aai_cli/app/init_exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,24 @@ def _install_step(
], will_launch


def _reject_file_ancestor(target: Path) -> None:
"""Reject a target that descends through an existing file (e.g. ``somefile/app``).

``scaffold`` calls ``target.mkdir(parents=True)``, which raises a raw
``NotADirectoryError`` mid-scaffold when a parent component is a regular file —
surfacing as an "Unexpected error … report a bug" line for what is really a bad
path. Catch it up front as a clean usage error instead.
"""
for ancestor in target.parents:
if ancestor.exists():
if not ancestor.is_dir():
raise UsageError(
f"{ancestor} is not a directory, so {target} can't be created.",
suggestion="Pick a target whose parent directories are real directories.",
)
return


def _resolve_target(
directory: str | None, chosen: str, *, here: bool, force: bool
) -> tuple[Path, bool]:
Expand All @@ -155,6 +173,7 @@ def _resolve_target(
target = _resolve_dir(directory, chosen, here=here)
if target.exists() and not target.is_dir():
raise UsageError(f"{target} exists and is not a directory.")
_reject_file_ancestor(target)
conflict = scaffold.target_conflict(target)
if conflict and not force:
raise CLIError(
Expand Down
14 changes: 11 additions & 3 deletions aai_cli/commands/clip/_exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,11 +193,19 @@ def _transcript_segments(


def _validate_out_dir(out_dir: Path | None) -> None:
if out_dir is not None and not out_dir.is_dir():
if out_dir is None or out_dir.is_dir():
return
# Distinguish a missing path from one that exists but isn't a directory: the old
# "doesn't exist" wording was misleading when --out-dir pointed at a regular file.
if out_dir.exists():
raise UsageError(
f"--out-dir doesn't exist: {out_dir}",
suggestion="Create it first, or point --out-dir at an existing directory.",
f"--out-dir is not a directory: {out_dir}",
suggestion="Point --out-dir at a directory, not a file.",
)
raise UsageError(
f"--out-dir doesn't exist: {out_dir}",
suggestion="Create it first, or point --out-dir at an existing directory.",
)


def _validate_selection(opts: ClipOptions) -> None:
Expand Down
4 changes: 3 additions & 1 deletion aai_cli/commands/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,9 @@ def body(_state: AppState, json_mode: bool) -> None:
# unwrapped (`cd "$(assembly config path | xargs dirname)"`).
output.emit_text(str(file))

run_command(ctx, body, json=json_out)
# The location is independent of the file's contents, so report it even when the
# config is unreadable — this is the command you'd use to go fix the broken file.
run_command(ctx, body, json=json_out, tolerate_unreadable_config=True)


@app.command(
Expand Down
3 changes: 2 additions & 1 deletion aai_cli/commands/stream/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from pathlib import Path

import typer
from assemblyai import PIISubstitutionPolicy
from assemblyai.streaming.v3 import Encoding, NoiseSuppressionModel, SpeechModel

from aai_cli import command_registry, help_panels, options
Expand Down Expand Up @@ -210,7 +211,7 @@ def stream(
help="Comma-separated PII policies",
rich_help_panel=help_panels.OPT_GUARDRAILS,
),
redact_pii_sub: str | None = typer.Option(
redact_pii_sub: PIISubstitutionPolicy | None = typer.Option(
None,
"--redact-pii-sub",
help="Replace redacted PII with: hash or entity_name",
Expand Down
5 changes: 3 additions & 2 deletions aai_cli/commands/stream/_exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from dataclasses import dataclass
from pathlib import Path

from assemblyai import PIISubstitutionPolicy
from assemblyai.streaming.v3 import Encoding, NoiseSuppressionModel, SpeechModel

from aai_cli import code_gen
Expand Down Expand Up @@ -67,7 +68,7 @@ class StreamOptions:
filter_profanity: bool | None
redact_pii: bool | None
redact_pii_policy: str | None
redact_pii_sub: str | None
redact_pii_sub: PIISubstitutionPolicy | None
webhook_url: str | None
webhook_auth_header: str | None
llm_prompt: list[str] | None
Expand Down Expand Up @@ -111,7 +112,7 @@ def base_flags(self) -> dict[str, object]:
"voice_focus_threshold": self.voice_focus_threshold,
"redact_pii": self.redact_pii,
"redact_pii_policies": config_builder.split_csv(self.redact_pii_policy),
"redact_pii_sub": self.redact_pii_sub,
"redact_pii_sub": config_builder.enum_value(self.redact_pii_sub),
"inactivity_timeout": self.inactivity_timeout,
"webhook_url": self.webhook_url,
"prompt": self.prompt,
Expand Down
23 changes: 18 additions & 5 deletions aai_cli/commands/telemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,24 @@ def enable(

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,
)
# An env kill-switch (AAI_TELEMETRY_DISABLED / DO_NOT_TRACK) outranks the stored
# choice, so persisting "enabled" wouldn't actually turn telemetry back on while
# it's set — say so instead of an unqualified success.
source = telemetry.consent_source()
env_var = source.removeprefix("env:") if source.startswith("env:") else None

def render(_d: dict[str, bool]) -> object:
line = output.success("Telemetry enabled.")
if env_var is None:
return line
return output.stack(
line,
output.hint(
f"Note: {env_var} is set, which keeps telemetry off until you unset it."
),
)

output.emit({"telemetry_enabled": True}, render, json_mode=json_mode)

run_command(ctx, body, json=json_out)

Expand Down
6 changes: 5 additions & 1 deletion aai_cli/commands/webhooks/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@
def listen(
ctx: typer.Context,
port: int = typer.Option(
8989, "--port", help="Local listener port (the first free port from here)"
8989,
"--port",
min=0,
max=65535,
help="Local listener port (the first free port from here)",
),
forward_to: str | None = typer.Option(
None, "--forward-to", help="Re-POST each delivery to this URL (e.g. your local app)"
Expand Down
23 changes: 17 additions & 6 deletions aai_cli/core/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from typing import TYPE_CHECKING, Any

from aai_cli.core import environments
from aai_cli.core.errors import APIError, UsageError
from aai_cli.core.errors import APIError, UsageError, auth_failure

if TYPE_CHECKING:
from openai import OpenAI
Expand Down Expand Up @@ -116,12 +116,18 @@ def _client(api_key: str) -> OpenAI:
)


def _is_entitlement_denial(exc: object) -> bool:
"""True when a gateway 401/403 reads as a plan-entitlement block rather than a
bad key or an intercepting proxy."""
text = f"{exc} {getattr(exc, 'body', None) or ''}".lower()
return any(hint in text for hint in _ENTITLEMENT_HINTS)


def _denial_suggestion(exc: object) -> str:
"""Pick the suggestion for a gateway 401/403: point at billing only when the
response actually mentions the plan entitlement, otherwise at key/network —
a corporate-proxy 403 must not send users to the billing page."""
text = f"{exc} {getattr(exc, 'body', None) or ''}".lower()
if any(hint in text for hint in _ENTITLEMENT_HINTS):
if _is_entitlement_denial(exc):
return _PAID_PLAN_SUGGESTION
return _ACCESS_DENIED_SUGGESTION

Expand Down Expand Up @@ -159,9 +165,14 @@ def complete(
)
except (openai.AuthenticationError, openai.PermissionDeniedError) as exc:
# The gateway returns 401/403 for an invalid key, a proxy block, and a
# plan entitlement block ("no access to LLM Gateway"), so surface its
# actual message and pick the suggestion from what it says — only an
# entitlement message should point at billing.
# plan entitlement block ("no access to LLM Gateway"). A plain 401
# (AuthenticationError) with no entitlement hint is just a rejected key, so
# surface the same clean exit-4 auth_failure transcribe gives instead of
# echoing the gateway's raw 401 body. A 403 (proxy or entitlement) keeps the
# gateway's own message and picks the suggestion from what it says — only an
# entitlement message should point at billing, never a corporate-proxy 403.
if isinstance(exc, openai.AuthenticationError) and not _is_entitlement_denial(exc):
raise auth_failure() from exc
raise APIError(
f"LLM Gateway access denied: {exc}",
suggestion=_denial_suggestion(exc),
Expand Down
15 changes: 13 additions & 2 deletions aai_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,8 +190,19 @@ def main(
try:
environments.set_active(state.resolve_environment())
except CLIError as err:
output.emit_error(err, json_mode=json_mode)
raise typer.Exit(code=err.exit_code) from None
if err.error_type != "invalid_config":
output.emit_error(err, json_mode=json_mode)
raise typer.Exit(code=err.exit_code) from None
# A corrupt config.toml can't tell us the profile's stored env. Defer the error
# (run_command re-raises it for real commands) and fall back to the explicit
# --env or the default, so `assembly config path` can still locate the file.
# An explicit bad --env still wins — surface it now rather than the deferred one.
state.deferred_config_error = err
try:
environments.set_active(environments.resolve(state.env, None))
except CLIError as env_err:
output.emit_error(env_err, json_mode=json_mode)
raise typer.Exit(code=env_err.exit_code) from None
active_env = environments.active()
_LOG.debug("environment: %s (%s)", active_env.name, active_env.api_base)
for warning in (conflict_warning, state.env_override_warning()):
Expand Down
46 changes: 35 additions & 11 deletions scripts/check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -200,8 +200,21 @@ echo "==> brew audit (Homebrew formula)"
# Lint the formula we ship (Formula/assembly.rb) the way Homebrew's own CI does, so a
# formula regression fails here instead of on the release PR. brew is macOS/Linuxbrew
# only, so this self-skips where it isn't installed (CI's release path has it).
#
# Homebrew 6+ disabled `brew audit [path ...]` — a formula must be audited by NAME,
# which means it has to live in a tap. Copy ours into an ephemeral local tap, audit by
# name, then remove it (works on both macOS and Linuxbrew, old and new). The explicit
# status capture keeps the tap cleanup running under `set -e` even when the audit fails.
if command -v brew >/dev/null 2>&1; then
brew audit --strict --formula Formula/assembly.rb
audit_tap="$(brew --repository)/Library/Taps/local/homebrew-aaiaudit"
mkdir -p "$audit_tap/Formula"
cp Formula/assembly.rb "$audit_tap/Formula/"
audit_status=0
brew audit --strict --formula local/aaiaudit/assembly || audit_status=$?
rm -rf "$audit_tap"
if [ "$audit_status" -ne 0 ]; then
exit "$audit_status"
fi
else
echo " brew not found; skipping (Homebrew CI / release runner has it)"
fi
Expand Down Expand Up @@ -256,11 +269,22 @@ if git rev-parse --verify --quiet origin/main >/dev/null; then
# though the branch itself added nothing. The merge-base only moves when the
# branch itself rebases.
gate_base="$(git merge-base origin/main HEAD || echo origin/main)"

# Count hatch hits with ONE matcher on both sides so baseline and working tree compare
# apples-to-apples. Using `rg` for the working tree but `git grep -E` for the baseline
# diverged on `\b`: ERE ignores it on macOS (matching nothing) while rg honors it, so a
# pre-existing time.sleep() inflated the working count over the baseline and failed this
# gate on macOS though it passed on Linux. `git grep -P` (PCRE) handles `\b` identically
# on both platforms; `--untracked` counts newly-added (unstaged) files the way rg did.
# Patterns must be PCRE-valid (escape literal parens, e.g. `cast\(`).
hatch_base() { { git grep -hP "$1" "$gate_base" -- "${@:2}" || true; } | wc -l | tr -d '[:space:]'; }
hatch_work() { { git grep --untracked -hP "$1" -- "${@:2}" || true; } | wc -l | tr -d '[:space:]'; }

hatch_pattern='# type: ignore|# noqa|pragma: no cover'
base_hatch_count="$({ git grep -nE "$hatch_pattern" "$gate_base" -- aai_cli tests || true; } | wc -l | tr -d '[:space:]')"
work_hatch_count="$({ rg -n "$hatch_pattern" aai_cli tests || true; } | wc -l | tr -d '[:space:]')"
base_hatch_count="$(hatch_base "$hatch_pattern" aai_cli tests)"
work_hatch_count="$(hatch_work "$hatch_pattern" aai_cli tests)"
if (( work_hatch_count > base_hatch_count )); then
{ rg -n "$hatch_pattern" aai_cli tests || true; } | tail -n 20
{ git grep --untracked -nP "$hatch_pattern" -- aai_cli tests || true; } | tail -n 20
echo "New static-analysis ignore/no-cover escape hatch found: ${work_hatch_count} current vs ${base_hatch_count} at the merge-base with origin/main. Refactor it or update the gate explicitly."
exit 1
fi
Expand All @@ -273,23 +297,23 @@ if git rev-parse --verify --quiet origin/main >/dev/null; then
# new one must update this gate deliberately. Scoped to tests/ — production sleeps
# are fine.
shortcut_pattern='pytest\.skip\(|pytest\.xfail\(|@pytest\.mark\.(skip|xfail)|\btime\.sleep\('
base_shortcut_count="$({ git grep -nE "$shortcut_pattern" "$gate_base" -- tests || true; } | wc -l | tr -d '[:space:]')"
work_shortcut_count="$({ rg -n "$shortcut_pattern" tests || true; } | wc -l | tr -d '[:space:]')"
base_shortcut_count="$(hatch_base "$shortcut_pattern" tests)"
work_shortcut_count="$(hatch_work "$shortcut_pattern" tests)"
if (( work_shortcut_count > base_shortcut_count )); then
{ rg -n "$shortcut_pattern" tests || true; } | tail -n 20
{ git grep --untracked -nP "$shortcut_pattern" -- tests || true; } | tail -n 20
echo "New test skip/xfail/time.sleep found: ${work_shortcut_count} current vs ${base_shortcut_count} at the merge-base with origin/main. Fix the test (or sync properly) or update the gate explicitly."
exit 1
fi

base_any_count="$({ git grep -n "Any" "$gate_base" -- aai_cli tests || true; } | wc -l | tr -d '[:space:]')"
work_any_count="$({ rg -n "Any" aai_cli tests || true; } | wc -l | tr -d '[:space:]')"
base_any_count="$(hatch_base "Any" aai_cli tests)"
work_any_count="$(hatch_work "Any" aai_cli tests)"
if (( work_any_count > base_any_count )); then
echo "New Any usage found: ${work_any_count} current vs ${base_any_count} at the merge-base with origin/main."
exit 1
fi

base_cast_count="$({ git grep -n "cast(" "$gate_base" -- aai_cli tests || true; } | wc -l | tr -d '[:space:]')"
work_cast_count="$({ rg -n "cast\\(" aai_cli tests || true; } | wc -l | tr -d '[:space:]')"
base_cast_count="$(hatch_base 'cast\(' aai_cli tests)"
work_cast_count="$(hatch_work 'cast\(' aai_cli tests)"
if (( work_cast_count > base_cast_count )); then
echo "New cast() usage found: ${work_cast_count} current vs ${base_cast_count} at the merge-base with origin/main."
exit 1
Expand Down
Loading
Loading