Skip to content

Commit be8b6ce

Browse files
authored
Improve CLI UX: enums, validation, help panels, and completions (#33)
1 parent 854d928 commit be8b6ce

19 files changed

Lines changed: 869 additions & 423 deletions

aai_cli/agent/voices.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,8 @@
4747
def format_voice_list() -> str:
4848
"""Human-readable, newline-separated voice IDs for --list-voices."""
4949
return "\n".join(VOICES)
50+
51+
52+
def complete_voice(incomplete: str) -> list[str]:
53+
"""Shell-completion callback for ``--voice``: known voice ids matching the prefix."""
54+
return [v for v in VOICES if v.startswith(incomplete)]

aai_cli/choices.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from __future__ import annotations
2+
3+
import enum
4+
5+
# CLI-owned closed value sets for ``-o/--output``. ``StrEnum`` members *are* their
6+
# string values: Typer renders them as choices in ``--help`` (e.g.
7+
# [text|id|status|utterances|srt|json]), validates input with a clean listing error,
8+
# and completes them on Tab — while existing ``field == "text"`` comparisons and
9+
# ``select_transcript_field(t, field)`` calls keep working unchanged.
10+
11+
12+
class TranscriptOutput(enum.StrEnum):
13+
"""Single-field output modes for a finished transcript (`transcribe`, `transcripts get`)."""
14+
15+
text = "text"
16+
id = "id"
17+
status = "status"
18+
utterances = "utterances"
19+
srt = "srt"
20+
json = "json"
21+
22+
23+
class TextOrJson(enum.StrEnum):
24+
"""Output mode for the streaming/LLM commands: plain finalized text or raw JSON."""
25+
26+
text = "text"
27+
json = "json"
28+
29+
30+
class Scope(enum.StrEnum):
31+
"""Coding-agent config scope for `aai setup` (passed through to `claude mcp add`)."""
32+
33+
user = "user"
34+
project = "project"
35+
local = "local"

aai_cli/client.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -134,10 +134,6 @@ def transcript_json_payload(transcript: Any) -> dict[str, object]:
134134
return getattr(transcript, "json_response", None) or transcript_summary(transcript)
135135

136136

137-
# Fields `transcribe` and `transcripts get` expose via `-o/--output` (raw, pipe-friendly).
138-
TRANSCRIPT_OUTPUT_FIELDS = ("text", "id", "status", "utterances", "srt", "json")
139-
140-
141137
def _transcript_text(transcript: Any) -> str:
142138
return str(getattr(transcript, "text", "") or "")
143139

aai_cli/commands/agent.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import typer
88

9-
from aai_cli import client, code_gen, config, help_panels, output
9+
from aai_cli import choices, client, code_gen, config, help_panels, output
1010
from aai_cli.agent.audio import SAMPLE_RATE, DuplexAudio, NullPlayer
1111
from aai_cli.agent.render import AgentRenderer
1212
from aai_cli.agent.session import (
@@ -15,7 +15,7 @@
1515
AgentRunConfig,
1616
run_session,
1717
)
18-
from aai_cli.agent.voices import DEFAULT_VOICE, VOICES, format_voice_list
18+
from aai_cli.agent.voices import DEFAULT_VOICE, VOICES, complete_voice, format_voice_list
1919
from aai_cli.context import AppState, run_command
2020
from aai_cli.errors import CLIError, UsageError
2121
from aai_cli.help_text import examples_epilog
@@ -83,24 +83,31 @@ def agent(
8383
sample: bool = typer.Option(
8484
False, "--sample", help="Speak the hosted wildfires.mp3 sample to the agent."
8585
),
86-
voice: str = typer.Option(DEFAULT_VOICE, "--voice", help="Agent voice. See --list-voices."),
86+
voice: str = typer.Option(
87+
DEFAULT_VOICE,
88+
"--voice",
89+
help="Agent voice. See --list-voices.",
90+
autocompletion=complete_voice,
91+
),
8792
system_prompt: str = typer.Option(
8893
DEFAULT_PROMPT, "--system-prompt", help="System prompt (the agent's persona)."
8994
),
9095
system_prompt_file: Path | None = typer.Option(
9196
None,
9297
"--system-prompt-file",
9398
help="Read the system prompt from a file (overrides --system-prompt).",
99+
exists=True,
100+
dir_okay=False,
94101
),
95102
greeting: str = typer.Option(DEFAULT_GREETING, "--greeting", help="Spoken greeting."),
96103
device: int | None = typer.Option(None, "--device", help="Microphone device index."),
97104
list_voices: bool = typer.Option(False, "--list-voices", help="Print known voices and exit."),
98105
json_out: bool = typer.Option(False, "--json", help="Emit newline-delimited JSON events."),
99-
output_field: str | None = typer.Option(
106+
output_field: choices.TextOrJson | None = typer.Option(
100107
None,
101108
"-o",
102109
"--output",
103-
help="Output mode: 'text' (you:/agent: lines as plain stdout, pipe-friendly) or 'json'.",
110+
help="Output mode: text (you:/agent: lines as plain stdout, pipe-friendly) or json.",
104111
),
105112
show_code: bool = typer.Option(
106113
False,

aai_cli/commands/llm.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import typer
66
from rich.markup import escape
77

8-
from aai_cli import config, help_panels, output, stdio
8+
from aai_cli import choices, config, help_panels, output, stdio
99
from aai_cli import llm as gateway
1010
from aai_cli.context import AppState, run_command
1111
from aai_cli.errors import UsageError
@@ -56,7 +56,12 @@ def llm(
5656
ctx: typer.Context,
5757
prompt: str | None = typer.Argument(None, help="The prompt to send to the model."),
5858
# Note: text piped on stdin is injected into the prompt (e.g. `cat notes | aai llm "summarize"`).
59-
model: str = typer.Option(gateway.DEFAULT_MODEL, "--model", help="LLM Gateway model."),
59+
model: str = typer.Option(
60+
gateway.DEFAULT_MODEL,
61+
"--model",
62+
help="LLM Gateway model.",
63+
autocompletion=gateway.complete_model,
64+
),
6065
transcript_id: str | None = typer.Option(
6166
None, "--transcript-id", help="Inject this transcript's text into the prompt."
6267
),
@@ -69,7 +74,7 @@ def llm(
6974
"the answer in place on every finalized turn (e.g. aai stream -o text | aai "
7075
'llm -f "summarize action items as I talk"). Ctrl-C to stop.',
7176
),
72-
output_field: str | None = typer.Option(
77+
output_field: choices.TextOrJson | None = typer.Option(
7378
None,
7479
"-o",
7580
"--output",
@@ -121,7 +126,6 @@ def body(state: AppState, json_mode: bool) -> None:
121126
suggestion="Or pass --list-models to see available models.",
122127
)
123128
prompt_text = prompt
124-
output.validate_output_field(output_field, ("text", "json"))
125129
api_key = config.resolve_api_key(profile=state.profile)
126130
# Text piped on stdin becomes the content the prompt operates on, unless an
127131
# explicit --transcript-id is given (that injects server-side and takes priority).

aai_cli/commands/setup.py

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,8 @@
88

99
import typer
1010

11-
from aai_cli import output
11+
from aai_cli import choices, output
1212
from aai_cli.context import AppState, run_command
13-
from aai_cli.errors import UsageError
1413
from aai_cli.help_text import examples_epilog
1514
from aai_cli.steps import Step, render_steps
1615

@@ -27,7 +26,6 @@
2726
MCP_NAME = "assemblyai-docs"
2827
MCP_URL = "https://mcp.assemblyai.com/docs"
2928
SKILL_REPO = "AssemblyAI/assemblyai-skill"
30-
_VALID_SCOPES = ("user", "project", "local")
3129
_STEPS_HEADING = "AssemblyAI coding-agent setup:"
3230

3331

@@ -300,24 +298,17 @@ def _render(data: dict[str, list[Step]]) -> str:
300298
)
301299
def install(
302300
ctx: typer.Context,
303-
scope: str = typer.Option(
304-
"user",
301+
scope: choices.Scope = typer.Option(
302+
choices.Scope.user,
305303
"--scope",
306-
help=(
307-
"Config scope to register the MCP under: user, project, or local. "
308-
"Presence is detected across all scopes."
309-
),
304+
help="Config scope to register the MCP under. Presence is detected across all scopes.",
310305
),
311306
force: bool = typer.Option(False, "--force", help="Reinstall even if already present."),
312307
json_out: bool = typer.Option(False, "--json", help="Output raw JSON."),
313308
) -> None:
314309
"""Install the AssemblyAI docs MCP server and skills into your coding agent."""
315310

316311
def body(_state: AppState, json_mode: bool) -> None:
317-
if scope not in _VALID_SCOPES:
318-
raise UsageError(
319-
f"Invalid --scope '{scope}'. Choose one of: {', '.join(_VALID_SCOPES)}."
320-
)
321312
steps = [_install_mcp(scope, force), _install_skill(force), _install_cli_skill(force)]
322313
output.emit({"steps": steps}, _render, json_mode=json_mode)
323314
if any(s["status"] == "failed" for s in steps):
@@ -355,11 +346,11 @@ def body(_state: AppState, json_mode: bool) -> None:
355346
)
356347
def remove(
357348
ctx: typer.Context,
358-
scope: str | None = typer.Option(
349+
scope: choices.Scope | None = typer.Option(
359350
None,
360351
"--scope",
361352
help=(
362-
"Only remove the MCP from this scope (user, project, or local). "
353+
"Only remove the MCP from this scope. "
363354
"Default: remove from whichever scope it exists in."
364355
),
365356
),
@@ -368,10 +359,6 @@ def remove(
368359
"""Remove the AssemblyAI MCP server and skills from your coding agent."""
369360

370361
def body(_state: AppState, json_mode: bool) -> None:
371-
if scope is not None and scope not in _VALID_SCOPES:
372-
raise UsageError(
373-
f"Invalid --scope '{scope}'. Choose one of: {', '.join(_VALID_SCOPES)}."
374-
)
375362
steps = [_remove_mcp(scope), _remove_skill(), _remove_cli_skill()]
376363
output.emit({"steps": steps}, _render, json_mode=json_mode)
377364
if any(s["status"] == "failed" for s in steps):

0 commit comments

Comments
 (0)