diff --git a/src/smallestai/cli/agent_crew.py b/src/smallestai/cli/agent_crew.py index 822f12cc..8c2a8fa7 100644 --- a/src/smallestai/cli/agent_crew.py +++ b/src/smallestai/cli/agent_crew.py @@ -2,6 +2,7 @@ import asyncio import base64 +import json as _json import sys from io import BytesIO from pathlib import Path @@ -36,6 +37,17 @@ def initialise_agent_crew_app(project_config: ProjectConfig, auth_client: AuthClient, atoms_client: AtomsAPIClient): app = typer.Typer(name="agent-crew") + def _resolve_agent_id(arg: Optional[str]) -> str: + """Explicit --agent-id wins; otherwise fall back to the linked project agent.""" + agent_id = arg or project_config.get_agent_id() + if not agent_id: + console.print( + "[red]No agent linked. Run [bold]smallestai agent-crew init[/bold] in this " + "directory, or pass [bold]--agent-id [/bold].[/red]" + ) + raise typer.Exit(1) + return agent_id + @app.command() def init( agent_id: Optional[str] = typer.Option( @@ -128,21 +140,20 @@ def deploy( "-e", help="Entry point file name (e.g., server.py)", ), + agent_id: Optional[str] = typer.Option( + None, "--agent-id", help="Agent id to deploy to (defaults to the linked project agent)." + ), ): """ Deploy an agent crew to the Atoms platform. Packages the agent-crew code directory into a zip file and deploys it to the backend. """ - asyncio.run(async_deploy(".", entry_point)) + asyncio.run(async_deploy(".", entry_point, agent_id)) - async def async_deploy(directory: str, entry_point: str): + async def async_deploy(directory: str, entry_point: str, agent_id_arg: Optional[str] = None): """Deploy an agent crew asynchronously.""" - agent_id = project_config.get_agent_id() - - if not agent_id: - console.print("[red]Agent not initialized. Run 'smallestai agent init' first.[/red]") - return + agent_id = _resolve_agent_id(agent_id_arg) # Check if user is logged in credentials = auth_client.get_credentials() @@ -315,23 +326,26 @@ async def run(): @app.command("builds") def list_builds( build_id: str = typer.Argument(None, help="Optional build ID to manage directly"), + agent_id: Optional[str] = typer.Option( + None, "--agent-id", help="Agent id (defaults to the linked project agent)." + ), limit: int = typer.Option(50, "--limit", "-l", help="Number of builds to fetch"), - offset: int = typer.Option(0, "--offset", "-o", help="Offset for pagination"), + offset: int = typer.Option(0, "--offset", help="Offset for pagination"), + as_json: bool = typer.Option(False, "--json", help="Emit raw JSON (non-interactive)"), ): """ List all builds for the current agent crew and manage them interactively. - If a build_id is provided, directly manage that specific build. + If a build_id is provided, directly manage that specific build. With --json + or in a non-interactive terminal, list and exit without the picker. """ - asyncio.run(async_list_builds(build_id, limit, offset)) + asyncio.run(async_list_builds(build_id, agent_id, limit, offset, as_json)) - async def async_list_builds(build_id: str | None, limit: int, offset: int): + async def async_list_builds( + build_id: str | None, agent_id_arg: Optional[str], limit: int, offset: int, as_json: bool + ): """Async implementation of list builds command.""" - agent_id = project_config.get_agent_id() - - if not agent_id: - console.print("[red]Agent not initialized. Run 'smallestai agent init' first.[/red]") - return + agent_id = _resolve_agent_id(agent_id_arg) credentials = auth_client.get_credentials() if not credentials or not credentials.get("access_token"): @@ -342,17 +356,27 @@ async def async_list_builds(build_id: str | None, limit: int, offset: int): try: if build_id: - console.print(f"[dim]Fetching build: {build_id}[/dim]") build = await atoms_client.get_agent_build( agent_id=agent_id, build_id=build_id, api_key=access_token, ) + if as_json: + console.print_json( + _json.dumps( + { + "id": build.id, + "status": build.status.value, + "is_live": build.is_live, + "created_at": build.created_at, + }, + default=str, + ) + ) + return await _manage_build(agent_id, build, access_token) return - console.print(f"[dim]Fetching builds for agent: {agent_id}[/dim]") - result = await atoms_client.list_agent_builds( agent_id=agent_id, api_key=access_token, @@ -360,6 +384,18 @@ async def async_list_builds(build_id: str | None, limit: int, offset: int): offset=offset, ) + if as_json: + console.print_json( + _json.dumps( + [ + {"id": b.id, "status": b.status.value, "is_live": b.is_live, "created_at": b.created_at} + for b in result.builds + ], + default=str, + ) + ) + return + if not result.builds: console.print("[yellow]No builds found for this agent.[/yellow]") return @@ -386,6 +422,14 @@ async def async_list_builds(build_id: str | None, limit: int, offset: int): console.print(table) console.print(f"[dim]Showing {len(result.builds)} of {result.pagination.total} builds[/dim]\n") + # No interactive picker outside a real terminal (would hang in CI). + if not sys.stdin.isatty(): + console.print( + "[dim]Manage a build directly: [bold]smallestai agent-crew builds [/bold] " + "(add [bold]--agent-id[/bold] outside a project dir).[/dim]" + ) + return + choices = [ questionary.Choice( title=f"{build.id[:12]}... | {build.status.value} | {'LIVE' if build.is_live else '-'} | {build.created_at}", @@ -467,6 +511,12 @@ async def _manage_build(agent_id: str, build, access_token: str): ) console.print(f"[bold green]✓ Build {build.id[:12]}... is now LIVE![/bold green]") elif selected_action == "take_down": + confirmed = await questionary.confirm( + f"Take build {build.id[:12]}... offline? This stops the agent serving calls." + ).ask_async() + if not confirmed: + console.print("[dim]Left the build live.[/dim]") + return console.print("[yellow]Taking down build...[/yellow]") await atoms_client.update_agent_build( agent_id=agent_id, @@ -479,19 +529,19 @@ async def _manage_build(agent_id: str, build, access_token: str): @app.command("logs") def build_logs( build_id: str = typer.Argument(None, help="Build ID to stream logs for (defaults to the latest build)"), + agent_id: Optional[str] = typer.Option( + None, "--agent-id", help="Agent id (defaults to the linked project agent)." + ), ): """Stream a build's logs (compile + deploy) in real time. With no build ID, streams the most recent build for the current agent. Use this to debug a deploy that failed or to watch one in progress. """ - asyncio.run(async_build_logs(build_id)) + asyncio.run(async_build_logs(build_id, agent_id)) - async def async_build_logs(build_id: str | None): - agent_id = project_config.get_agent_id() - if not agent_id: - console.print("[red]Agent not initialized. Run 'smallestai agent-crew init' first.[/red]") - raise typer.Exit(1) + async def async_build_logs(build_id: str | None, agent_id_arg: Optional[str] = None): + agent_id = _resolve_agent_id(agent_id_arg) credentials = auth_client.get_credentials() if not credentials or not credentials.get("access_token"): diff --git a/src/smallestai/cli/agents.py b/src/smallestai/cli/agents.py index 6631c837..7d5db5ad 100644 --- a/src/smallestai/cli/agents.py +++ b/src/smallestai/cli/agents.py @@ -6,6 +6,8 @@ overrides the endpoint (dev rig). """ +import json as _json + import typer from rich.console import Console from rich.table import Table @@ -26,20 +28,34 @@ def initialise_agents_app(auth_client: AuthClient): agents_app = typer.Typer(name="agents", help="Create, inspect, and call Atoms agents.") @agents_app.command("list") - def list_agents(): + def list_agents(as_json: bool = typer.Option(False, "--json", help="Emit raw JSON")): """List agents in your org.""" from smallestai.atoms.helpers import as_page pg = as_page(_client(auth_client).atoms.agents.list_agents()) + if as_json: + console.print_json( + _json.dumps( + [ + {"id": getattr(a, "id", None) or getattr(a, "_id", None), "name": getattr(a, "name", None)} + for a in pg.items + ], + default=str, + ) + ) + return table = Table("ID", "Name", title=f"Agents ({len(pg.items)})") for a in pg.items: table.add_row(getattr(a, "id", None) or getattr(a, "_id", "?"), getattr(a, "name", "—")) console.print(table) @agents_app.command("get") - def get_agent(agent_id: str): + def get_agent(agent_id: str, as_json: bool = typer.Option(False, "--json", help="Emit raw JSON")): """Show one agent's config.""" a = _client(auth_client).atoms.agents.get_agent(id=agent_id).data + if as_json: + console.print_json(a.json() if hasattr(a, "json") else _json.dumps(a, default=str)) + return console.print(f"[bold]{getattr(a, 'name', '—')}[/bold] [dim]{agent_id}[/dim]") console.print(f" first message : {getattr(a, 'first_message', None)!r}") console.print(f" language : {getattr(a, 'language', None)}") diff --git a/src/smallestai/cli/main.py b/src/smallestai/cli/main.py index c2b098f3..c9f2d1c8 100644 --- a/src/smallestai/cli/main.py +++ b/src/smallestai/cli/main.py @@ -1,9 +1,12 @@ -"""CLI for managing Atoms agent swarms — build, deploy, and chat with multi-node agent swarms.""" +"""CLI for building, deploying, and running Smallest AI voice agents and speech models.""" + +import sys import typer from rich.console import Console from rich.table import Table +from smallestai import __version__ from smallestai.cli.agent_crew import initialise_agent_crew_app from smallestai.cli.agents import initialise_agents_app from smallestai.cli.auth import initialise_auth_app @@ -12,6 +15,7 @@ from smallestai.cli.campaigns import initialise_campaigns_app from smallestai.cli.lib.atoms import AtomsAPIClient from smallestai.cli.lib.auth import AuthClient +from smallestai.cli.lib.client import make_client from smallestai.cli.lib.project_config import ProjectConfig from smallestai.cli.mcp import initialise_mcp_app from smallestai.cli.phone_numbers import initialise_phone_numbers_app @@ -19,38 +23,95 @@ console = Console() -app = typer.Typer(help="SmallestAI CLI", no_args_is_help=False, rich_markup_mode="rich") - -_COMMANDS = [ - ("agent-crew", "Init, deploy, and manage crew (custom-LLM) voice agents"), - ("agents", "Create, inspect, and call voice agents"), - ("calls", "Inspect call logs, transcripts, and recordings"), - ("campaigns", "Manage outbound calling campaigns"), - ("phone-numbers", "Search, rent, and manage phone numbers"), - ("models", "Text-to-speech, speech-to-text, and voices"), - ("mcp", "Set up the Smallest AI MCP server for Cursor / Claude"), - ("auth", "Log in and manage credentials"), +DASHBOARD_URL = "https://app.smallest.ai/dashboard" +DOCS_URL = "https://docs.smallest.ai" + +app = typer.Typer( + help="Build, deploy, and run Smallest AI voice agents and speech models.", + no_args_is_help=False, + rich_markup_mode="rich", +) + +# Grouped command list shown on the bare `smallestai` welcome screen. +_GROUPS = [ + ( + "BUILD & DEPLOY", + [ + ("agent-crew init", "Link a crew (custom-LLM) agent to this project"), + ("agent-crew deploy", "Package and deploy your crew code"), + ("agent-crew builds", "List builds; make live, take down, or manage"), + ("agent-crew logs", "Stream a build's compile + deploy logs"), + ("agent-crew doctor", "Check a crew agent's config for gotchas"), + ], + ), + ( + "VOICE AGENTS", + [ + ("agents", "Create, inspect, and call voice agents"), + ], + ), + ( + "TELEPHONY", + [ + ("calls", "Inspect call logs, transcripts, and recordings"), + ("campaigns", "Manage outbound calling campaigns"), + ("phone-numbers", "Search, rent, and manage phone numbers"), + ], + ), + ( + "SPEECH", + [ + ("models", "Text-to-speech, speech-to-text, and voices"), + ], + ), + ( + "SETUP", + [ + ("auth", "Log in and manage credentials"), + ("mcp", "Set up the Smallest AI MCP server for Cursor / Claude"), + ("status", "Show login, account, and linked agent"), + ("doctor", "Diagnose your environment and connectivity"), + ], + ), ] def _print_welcome() -> None: print_banner() console.print("\n [dim]Build, deploy, and run voice agents and speech models.[/dim]\n") - table = Table(show_header=False, box=None, padding=(0, 2, 0, 2)) - table.add_column(style="bold cyan", no_wrap=True) - table.add_column(style="white") - for name, desc in _COMMANDS: - table.add_row(name, desc) - console.print(table) + for header, rows in _GROUPS: + console.print(f" [bold #3B82F6]{header}[/bold #3B82F6]") + table = Table(show_header=False, box=None, padding=(0, 2, 0, 4)) + table.add_column(style="bold cyan", no_wrap=True) + table.add_column(style="white") + for name, desc in rows: + table.add_row(name, desc) + console.print(table) + console.print() console.print( - "\n [dim]Run [bold]smallestai --help[/bold] for details, " - "or [bold]smallestai --help[/bold] for everything.[/dim]\n" + " [dim]Run [bold]smallestai --help[/bold] for details.[/dim]\n" + " [dim][bold]smallestai version[/bold] | [bold]smallestai docs[/bold] | " + "[bold]smallestai --help[/bold][/dim]\n" ) +def _version_string() -> str: + return f"smallestai {__version__}" + + @app.callback(invoke_without_command=True) -def _root(ctx: typer.Context) -> None: - """SmallestAI CLI.""" +def _root( + ctx: typer.Context, + version_flag: bool = typer.Option(False, "--version", "-V", help="Show the version and exit", is_eager=True), +) -> None: + """Build, deploy, and run Smallest AI voice agents and speech models. + + Auth reads SMALLEST_API_KEY (or `smallestai auth login`); SMALLEST_BASE_URL + overrides the API host. + """ + if version_flag: + console.print(_version_string()) + raise typer.Exit() if ctx.invoked_subcommand is None: _print_welcome() raise typer.Exit() @@ -86,9 +147,155 @@ def _root(ctx: typer.Context) -> None: app.add_typer(initialise_mcp_app(), name="mcp") -def main(): - import sys +# ------------------------------------------------------------------ convenience commands + + +@app.command() +def version() -> None: + """Show the installed smallestai version.""" + console.print(_version_string()) + + +def _account(): + """(email, name, org) for the logged-in user, or None if not logged in.""" + creds = auth_client.get_credentials() + if not creds or not creds.get("access_token"): + return None + try: + d = make_client(auth_client).atoms.user.get_user_details() + u = getattr(d, "data", d) + name = " ".join(x for x in [getattr(u, "first_name", None), getattr(u, "last_name", None)] if x) + return { + "email": getattr(u, "user_email", None), + "name": name or None, + "organization_id": getattr(u, "organization_id", None), + } + except Exception: + return {"email": None, "name": None, "organization_id": None} + + +@app.command() +def whoami(as_json: bool = typer.Option(False, "--json", help="Emit raw JSON")) -> None: + """Show the currently authenticated account.""" + acct = _account() + if acct is None: + console.print("[yellow]Not logged in. Run [bold]smallestai auth login[/bold].[/yellow]") + raise typer.Exit(1) + if as_json: + import json as _json + + console.print_json(_json.dumps(acct, default=str)) + return + console.print(f" {acct.get('name') or '—'} <{acct.get('email') or '—'}>") + console.print(f" org: {acct.get('organization_id') or '—'}") + + +@app.command() +def status(as_json: bool = typer.Option(False, "--json", help="Emit raw JSON")) -> None: + """Show login, account, linked agent, and version — handy for bug reports.""" + import os + creds = auth_client.get_credentials() + logged_in = bool(creds and creds.get("access_token")) + if os.environ.get("SMALLEST_API_KEY"): + key_source = "env (SMALLEST_API_KEY)" + elif logged_in: + key_source = "stored" + else: + key_source = "none" + acct = _account() if logged_in else None + linked_agent = project_config.get_agent_id() + info = { + "version": __version__, + "logged_in": logged_in, + "key_source": key_source, + "email": (acct or {}).get("email"), + "organization_id": (acct or {}).get("organization_id"), + "linked_agent_id": linked_agent, + } + if as_json: + import json as _json + + console.print_json(_json.dumps(info, default=str)) + return + console.print(f" smallestai : {__version__}") + console.print(f" logged in : {'yes' if logged_in else 'no'} ({key_source})") + console.print(f" account : {(acct or {}).get('email') or '—'}") + console.print(f" organization : {(acct or {}).get('organization_id') or '—'}") + console.print(f" linked agent : {linked_agent or '[dim]none (not in a crew project)[/dim]'}") + + +@app.command("open") +def open_dashboard( + agent_id: str = typer.Argument(None, help="Open a specific agent (defaults to the dashboard root)"), +) -> None: + """Open the Smallest AI dashboard in your browser.""" + url = f"{DASHBOARD_URL}/agents/{agent_id}" if agent_id else DASHBOARD_URL + if sys.stdout.isatty(): + typer.launch(url) + else: + console.print(url) + + +@app.command() +def docs(topic: str = typer.Argument(None, help="Open a docs topic path (optional)")) -> None: + """Open the Smallest AI docs in your browser.""" + url = f"{DOCS_URL}/{topic.lstrip('/')}" if topic else DOCS_URL + if sys.stdout.isatty(): + typer.launch(url) + else: + console.print(url) + + +@app.command() +def doctor() -> None: + """Diagnose your environment and connectivity (like `fly doctor`).""" + import os + import shutil + + ok, warn = [], [] + + if os.environ.get("SMALLEST_API_KEY"): + ok.append("SMALLEST_API_KEY is set") + else: + creds = auth_client.get_credentials() + if creds and creds.get("access_token"): + ok.append("stored credentials present") + else: + warn.append("not logged in — set SMALLEST_API_KEY or run `smallestai auth login`") + + acct = _account() + if acct and acct.get("email"): + ok.append(f"reached api.smallest.ai as {acct['email']}") + elif acct is not None: + warn.append("logged in but could not fetch account (API unreachable or key invalid)") + + # best-effort: compare installed vs latest on PyPI (skip silently on no network) + try: + import httpx + + latest = httpx.get("https://pypi.org/pypi/smallestai/json", timeout=5).json()["info"]["version"] + if latest == __version__: + ok.append(f"smallestai {__version__} (latest)") + else: + warn.append(f"smallestai {__version__} installed; {latest} available (pip install -U smallestai)") + except Exception: + ok.append(f"smallestai {__version__}") + + if shutil.which("npx"): + ok.append("npx present (needed for `smallestai mcp run`)") + else: + warn.append("npx not found — needed for `smallestai mcp run` (install Node.js)") + + for line in ok: + console.print(f" [green]✓[/green] {line}") + for line in warn: + console.print(f" [yellow]![/yellow] {line}") + if warn: + raise typer.Exit(1) + + +def main(): from smallestai import telemetry telemetry.maybe_show_first_run_notice() diff --git a/tests/custom/test_cli_ux.py b/tests/custom/test_cli_ux.py new file mode 100644 index 00000000..f515437f --- /dev/null +++ b/tests/custom/test_cli_ux.py @@ -0,0 +1,64 @@ +"""CLI UX additions: version, grouped help, docs/open URL fallback.""" + +import re + +from typer.testing import CliRunner + +from smallestai import __version__ +from smallestai.cli.main import app + +runner = CliRunner() + +_ANSI = re.compile(r"\x1b\[[0-9;]*m") +# Render help wide so Rich doesn't wrap/truncate option names at 80 cols (CI). +_WIDE = {"COLUMNS": "200", "TERM": "dumb"} + + +def _plain_help(args): + res = runner.invoke(app, args, env=_WIDE) + return res, _ANSI.sub("", res.output) + + +def test_version_command_and_flag(): + for args in (["version"], ["--version"]): + res = runner.invoke(app, args) + assert res.exit_code == 0 + assert __version__ in res.output + + +def test_welcome_shows_grouped_commands(): + res = runner.invoke(app, []) + assert res.exit_code == 0 + for group in ("BUILD & DEPLOY", "VOICE AGENTS", "TELEPHONY", "SPEECH", "SETUP"): + assert group in res.output + for cmd in ("agent-crew", "agents", "calls", "models", "mcp", "status"): + assert cmd in res.output + + +def test_docs_prints_url_when_not_a_tty(): + # CliRunner stdout is not a TTY, so docs/open print the URL instead of launching. + res = runner.invoke(app, ["docs"]) + assert res.exit_code == 0 + assert "docs.smallest.ai" in res.output + res = runner.invoke(app, ["open"]) + assert res.exit_code == 0 + assert "app.smallest.ai" in res.output + + +def test_agent_crew_commands_have_agent_id_and_json(): + for args in ( + ["agent-crew", "builds", "--help"], + ["agent-crew", "deploy", "--help"], + ["agent-crew", "logs", "--help"], + ): + res, plain = _plain_help(args) + assert res.exit_code == 0 + assert "--agent-id" in plain + _, plain = _plain_help(["agent-crew", "builds", "--help"]) + assert "--json" in plain + + +if __name__ == "__main__": + import pytest + + pytest.main([__file__, "-v"])