diff --git a/aai_cli/agent/voices.py b/aai_cli/agent/voices.py index 91aa1690..f53bb20d 100644 --- a/aai_cli/agent/voices.py +++ b/aai_cli/agent/voices.py @@ -2,6 +2,8 @@ from dataclasses import dataclass +from aai_cli.core import choices + @dataclass(frozen=True) class Voice: @@ -71,4 +73,4 @@ def format_voice_list() -> str: def complete_voice(incomplete: str) -> list[str]: """Shell-completion callback for ``--voice``: known voice ids matching the prefix.""" - return [name for name in VOICE_NAMES if name.startswith(incomplete)] + return choices.complete_prefix(VOICE_NAMES, incomplete) diff --git a/aai_cli/agent_framework/voices.py b/aai_cli/agent_framework/voices.py index de55bb6f..57bdc006 100644 --- a/aai_cli/agent_framework/voices.py +++ b/aai_cli/agent_framework/voices.py @@ -9,6 +9,7 @@ from __future__ import annotations +from aai_cli.core import choices from aai_cli.tts import voices as tts_voices DEFAULT_VOICE = "jane" @@ -29,7 +30,7 @@ def complete_voice(incomplete: str) -> list[str]: """Shell-completion callback for ``--voice``: catalog ids matching the prefix.""" - return [name for name in VOICE_NAMES if name.startswith(incomplete)] + return choices.complete_prefix(VOICE_NAMES, incomplete) def format_voice_list() -> str: diff --git a/aai_cli/app/agent_shared.py b/aai_cli/app/agent_shared.py new file mode 100644 index 00000000..27699dab --- /dev/null +++ b/aai_cli/app/agent_shared.py @@ -0,0 +1,27 @@ +"""Run-logic shared by the two voice commands (`agent` and `agent-framework`). + +Both build a live terminal conversation and resolve the persona the same way, so +the shared piece lives in the `app/` layer (the `doctor_checks`/`setup_exec` +precedent) rather than being copied between the two command packages. +""" + +from __future__ import annotations + +from pathlib import Path + +from aai_cli.core.errors import CLIError + + +def resolve_system_prompt(system_prompt: str, system_prompt_file: Path | None) -> str: + """The persona text: a --system-prompt-file (if given) overrides --system-prompt.""" + if system_prompt_file is None: + return system_prompt + try: + return system_prompt_file.read_text(encoding="utf-8") + except OSError as exc: + raise CLIError( + 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 diff --git a/aai_cli/commands/agent/_exec.py b/aai_cli/commands/agent/_exec.py index 00395438..60c16441 100644 --- a/aai_cli/commands/agent/_exec.py +++ b/aai_cli/commands/agent/_exec.py @@ -20,9 +20,10 @@ from aai_cli.agent.render import AgentRenderer from aai_cli.agent.session import AgentRunConfig, run_session from aai_cli.agent.voices import VOICE_NAMES +from aai_cli.app.agent_shared import resolve_system_prompt as _resolve_system_prompt from aai_cli.app.context import AppState from aai_cli.core import choices, client -from aai_cli.core.errors import CLIError, UsageError +from aai_cli.core.errors import UsageError from aai_cli.streaming.session import resolve_output_modes from aai_cli.streaming.sources import FileSource from aai_cli.ui import output @@ -48,21 +49,6 @@ class AgentOptions: show_code: bool -def _resolve_system_prompt(system_prompt: str, system_prompt_file: Path | None) -> str: - """The persona text: a --system-prompt-file (if given) overrides --system-prompt.""" - if system_prompt_file is None: - return system_prompt - try: - return system_prompt_file.read_text(encoding="utf-8") - except OSError as exc: - raise CLIError( - 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 - - def _open_audio( renderer: AgentRenderer, *, diff --git a/aai_cli/commands/agent_framework/_exec.py b/aai_cli/commands/agent_framework/_exec.py index 71ee6d8d..5b7aefdc 100644 --- a/aai_cli/commands/agent_framework/_exec.py +++ b/aai_cli/commands/agent_framework/_exec.py @@ -18,9 +18,10 @@ from aai_cli.agent.render import AgentRenderer from aai_cli.agent_framework import engine, voices from aai_cli.agent_framework.config import CascadeConfig +from aai_cli.app.agent_shared import resolve_system_prompt as _resolve_system_prompt from aai_cli.app.context import AppState from aai_cli.core import choices, client -from aai_cli.core.errors import CLIError, UsageError +from aai_cli.core.errors import UsageError from aai_cli.streaming.session import resolve_output_modes from aai_cli.streaming.sources import FileSource from aai_cli.tts import session as tts_session @@ -46,21 +47,6 @@ class AgentFrameworkOptions: output_field: choices.TextOrJson | None -def _resolve_system_prompt(system_prompt: str, system_prompt_file: Path | None) -> str: - """The persona text: a --system-prompt-file (if given) overrides --system-prompt.""" - if system_prompt_file is None: - return system_prompt - try: - return system_prompt_file.read_text(encoding="utf-8") - except OSError as exc: - raise CLIError( - 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 - - def _open_audio( renderer: AgentRenderer, *, diff --git a/aai_cli/core/choices.py b/aai_cli/core/choices.py index b54961c2..fe3d4da6 100644 --- a/aai_cli/core/choices.py +++ b/aai_cli/core/choices.py @@ -1,6 +1,13 @@ from __future__ import annotations import enum +from collections.abc import Iterable + + +def complete_prefix(options: Iterable[str], incomplete: str) -> list[str]: + """Shell-completion callback body: the ``options`` that start with the typed prefix.""" + return [option for option in options if option.startswith(incomplete)] + # CLI-owned closed value sets for ``-o/--output``. ``StrEnum`` members *are* their # string values: Typer renders them as choices in ``--help`` (e.g. diff --git a/aai_cli/core/llm.py b/aai_cli/core/llm.py index 2f9fc740..c27e0aac 100644 --- a/aai_cli/core/llm.py +++ b/aai_cli/core/llm.py @@ -4,7 +4,7 @@ from collections.abc import Sequence from typing import TYPE_CHECKING -from aai_cli.core import environments +from aai_cli.core import choices, environments from aai_cli.core.errors import APIError, UsageError, auth_failure if TYPE_CHECKING: @@ -41,7 +41,7 @@ def complete_model(incomplete: str) -> list[str]: The gateway accepts more than this curated list, so completion only *suggests* these — it never restricts what you can type. """ - return [m for m in KNOWN_MODELS if m.startswith(incomplete)] + return choices.complete_prefix(KNOWN_MODELS, incomplete) def parse_gateway_overrides(pairs: Sequence[str]) -> dict[str, object]: diff --git a/tests/test_agent_framework_command.py b/tests/test_agent_framework_command.py index cd4d4f52..2a9a5798 100644 --- a/tests/test_agent_framework_command.py +++ b/tests/test_agent_framework_command.py @@ -116,6 +116,8 @@ def test_resolve_system_prompt_unreadable_file_errors(tmp_path): with pytest.raises(CLIError, match="Could not read --system-prompt-file") as exc: _exec._resolve_system_prompt("x", missing) assert exc.value.exit_code == 2 + assert exc.value.error_type == "file_not_found" + assert "readable" in (exc.value.suggestion or "") # --- _open_audio ------------------------------------------------------------- diff --git a/tests/test_completion.py b/tests/test_completion.py index 24635e71..87802973 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -3,10 +3,17 @@ import typer from aai_cli.agent.voices import VOICE_NAMES, complete_voice +from aai_cli.core import choices from aai_cli.core.llm import KNOWN_MODELS, complete_model from aai_cli.main import app +def test_complete_prefix_keeps_only_matching_options(): + # The shared completion body that complete_voice/complete_model delegate to: it + # filters to the prefix (dropping "banana") rather than echoing every option back. + assert choices.complete_prefix(["apple", "apricot", "banana"], "ap") == ["apple", "apricot"] + + def test_shell_completion_is_enabled(): # add_completion=True registers Typer's --install-completion on the root command. # Introspect the Click command rather than rendered --help text, which wraps at