Skip to content
Open
943 changes: 943 additions & 0 deletions docs/docs/reference/cli.md

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CLI reference has been updated, I would take another look at this please.
Basically this file is for high level, then each sub command has it's own page

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions docs/docs/reference/resources/functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ They can be called by the model, used as flow steps, or run automatically at cal

Functions are how the ADK handles behavior that should not be left to prompt interpretation alone.

Creating, editing and deleting functions is always done here, via local files synced by `poly push`/`poly pull`. To run, validate, deploy or inspect a function's references headlessly (e.g. from CI) once it exists, see [`poly functions`](cli.md#poly-functions).

## Where functions live

~~~text
Expand Down
53 changes: 49 additions & 4 deletions src/poly/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,19 @@

from poly.cli_commands.audio_cache import AudioCacheCommand
from poly.cli_commands.auth import LoginCommand, StartCommand
from poly.cli_commands.base import BaseCommand, Parents
from poly.cli_commands.base import (
COMMAND_GROUP_ORDER,
BaseCommand,
GroupedHelpFormatter,
Parents,
add_grouped_subparsers,
group_subcommands,
)
from poly.cli_commands.branch import BranchCommand
from poly.cli_commands.chat import ChatCommand
from poly.cli_commands.conversations import ConversationsCommand
from poly.cli_commands.deployments import DeploymentsCommand
from poly.cli_commands.functions import FunctionsCommand
from poly.cli_commands.project import InitCommand, ProjectCommand, StudioCommand
from poly.cli_commands.review import ReviewCommand
from poly.cli_commands.rtc import RTCCommand
Expand All @@ -34,6 +42,7 @@
from poly.cli_commands.template import TemplateCommand
from poly.cli_commands.testing import TestingCommand
from poly.cli_commands.utils import CompletionCommand, DocsCommand
from poly.handlers.interface import REGIONS
from poly.output.json_output import json_print

logger = logging.getLogger(__name__)
Expand All @@ -57,6 +66,7 @@
DeploymentsCommand,
ConversationsCommand,
AudioCacheCommand,
FunctionsCommand,
TestingCommand,
RTCCommand,
ChatCommand,
Expand All @@ -79,7 +89,7 @@ def _create_parser(self):
_version = get_package_version("polyai-adk")
except Exception:
_version = "unknown"
parser = ArgumentParser()
parser = ArgumentParser(formatter_class=GroupedHelpFormatter)
parser.add_argument(
"-v",
"--version",
Expand Down Expand Up @@ -119,15 +129,50 @@ def _create_parser(self):
help="Base path to the project. Defaults to current working directory.",
)

scope_parent = ArgumentParser(add_help=False)
scope_parent.add_argument(
"--region",
type=str,
choices=REGIONS,
default=None,
help="Region, for headless use without a local project. Requires "
"--project_id and --branch_id.",
)
scope_parent.add_argument(
"--project_id",
type=str,
default=None,
help="Project ID (agent ID), for headless use without a local project. "
"Requires --region and --branch_id.",
)
scope_parent.add_argument(
"--branch_id",
type=str,
default=None,
help="Branch ID, for headless use without a local project. Requires "
"--region and --project_id.",
)

parents = Parents(
verbose=verbose_parent, json=json_parent, debug=debug_parent, path=path_parent
verbose=verbose_parent,
json=json_parent,
debug=debug_parent,
path=path_parent,
scope=scope_parent,
)

subparsers = parser.add_subparsers(dest="command", required=True)
subparsers = add_grouped_subparsers(parser, dest="command", metavar="<command>")

for command in self.commands:
command.add_arguments(subparsers, parents=parents)

# Split the (long) flat command list into titled sections for --help.
group_subcommands(
subparsers,
{command.command: command.group for command in self.commands},
COMMAND_GROUP_ORDER,
)

return parser

def _run_command(self, args):
Expand Down
4 changes: 3 additions & 1 deletion src/poly/cli_commands/audio_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from argparse import ArgumentParser, Namespace, RawTextHelpFormatter, _SubParsersAction
from typing import Optional

from poly.cli_commands.base import BaseCommand, Parents
from poly.cli_commands.base import BUILDER_API_GROUP, BaseCommand, Parents
from poly.cli_commands.shared import load_project
from poly.handlers.interface import AgentStudioInterface
from poly.output.json_output import json_print
Expand All @@ -21,6 +21,8 @@ class AudioCacheCommand(BaseCommand):

command = "audio-cache"

group = BUILDER_API_GROUP

@classmethod
def add_arguments(cls, subparsers: _SubParsersAction[ArgumentParser], parents: Parents) -> None:
"""Register the ``audio-cache`` subcommand tree."""
Expand Down
6 changes: 5 additions & 1 deletion src/poly/cli_commands/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import sys
from argparse import ArgumentParser, Namespace, _SubParsersAction

from poly.cli_commands.base import BaseCommand, Parents
from poly.cli_commands.base import GETTING_STARTED_GROUP, BaseCommand, Parents
from poly.cli_commands.project import ProjectCommand
from poly.handlers.auth0_handler import Auth0Handler
from poly.handlers.interface import REGIONS, AgentStudioInterface
Expand Down Expand Up @@ -116,6 +116,8 @@ class StartCommand(BaseCommand):

command = "start"

group = GETTING_STARTED_GROUP

@classmethod
def add_arguments(cls, subparsers: _SubParsersAction[ArgumentParser], parents: Parents) -> None:
"""Register the ``start`` subcommand."""
Expand Down Expand Up @@ -226,6 +228,8 @@ class LoginCommand(BaseCommand):

command = "login"

group = GETTING_STARTED_GROUP

@classmethod
def add_arguments(cls, subparsers: _SubParsersAction[ArgumentParser], parents: Parents) -> None:
"""Register the ``login`` subcommand."""
Expand Down
165 changes: 164 additions & 1 deletion src/poly/cli_commands/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,35 @@
Copyright PolyAI Limited
"""

import re
from abc import ABC, abstractmethod
from argparse import ArgumentParser, Namespace, _SubParsersAction
from argparse import (
SUPPRESS,
Action,
ArgumentParser,
HelpFormatter,
Namespace,
RawTextHelpFormatter,
_SubParsersAction,
)
from dataclasses import dataclass

# Section headers for ``poly --help``, in the order they are displayed. Every
# command's ``group`` must be one of these; ``OTHER_GROUP`` collects anything
# that has not been assigned a group yet so a new command is never dropped from
# the help output.
GETTING_STARTED_GROUP = "Getting started"
PROJECT_SYNC_GROUP = "Project sync"
BUILDER_API_GROUP = "Builder API"
OTHER_GROUP = "Other"

COMMAND_GROUP_ORDER = [
GETTING_STARTED_GROUP,
PROJECT_SYNC_GROUP,
BUILDER_API_GROUP,
OTHER_GROUP,
]


@dataclass
class Parents:
Expand All @@ -20,12 +45,14 @@ class Parents:
json: ArgumentParser
debug: ArgumentParser
path: ArgumentParser
scope: ArgumentParser


class BaseCommand(ABC):
"""Base class for CLI commands."""

command: str = "base"
group: str = OTHER_GROUP

@classmethod
@abstractmethod
Expand All @@ -40,3 +67,139 @@ def add_arguments(
def run(cls, args: Namespace) -> None:
"""Run the command with the provided arguments."""
pass


class _GroupHeaderAction(Action):
"""A help-only pseudo-action that renders a section header.

argparse has no notion of grouped subcommands, but it does render one line
per entry in a subparsers action's help list. A header is therefore just an
entry whose displayed name is a blank line followed by the group title, and
which carries no help text of its own — so argparse prints it flush left,
the same way it prints ``options:``.
"""

def __init__(self, title: str):
super().__init__(option_strings=[], dest=title, help=None, metavar=f"\n{title}:")

def __call__(self, *args: object, **kwargs: object) -> None:
"""Never invoked — this action exists only to be formatted into help."""
raise NotImplementedError("group headers are display-only")


class _GroupedHelpMixin:
"""Help-formatting half of the grouped-subcommand mechanism.

Mixed into a concrete argparse formatter so the same behaviour can be
combined with either the default or the raw-text description handling.
"""

def _format_action(self, action: Action) -> str:
"""Format an action, hiding the subparsers placeholder line.

The subparsers action would otherwise print its own ``<command>``
metavar line above the commands. The group headers already title each
section, so that line is redundant — drop it and keep the entries.
"""
formatted = super()._format_action(action)
if isinstance(action, _SubParsersAction):
_placeholder, _, entries = formatted.partition("\n")
return entries
return formatted

def format_help(self) -> str:
"""Format the help text, tidying up whitespace around the headers.

Each header carries a leading newline to escape argparse's indentation,
which leaves the indent stranded as trailing whitespace on the blank
line before it, and doubles up the blank line after the usage block.
"""
text = "\n".join(line.rstrip() for line in super().format_help().split("\n"))
return re.sub(r"\n{3,}", "\n\n", text)


class GroupedHelpFormatter(_GroupedHelpMixin, HelpFormatter):
"""Formatter for a grouped parser whose help text should wrap normally."""


class GroupedRawTextHelpFormatter(_GroupedHelpMixin, RawTextHelpFormatter):
"""Formatter for a grouped parser with a hand-formatted description block."""


def add_grouped_subparsers(
parser: ArgumentParser,
dest: str,
metavar: str,
required: bool = True,
) -> "_SubParsersAction[ArgumentParser]":
"""Add a subparsers action whose ``--help`` listing carries group headers.

The parser must use ``GroupedHelpFormatter`` or
``GroupedRawTextHelpFormatter``. Register the subparsers as usual, then call
``group_subcommands`` once they all exist.

Args:
parser: The parser to add the subparsers action to.
dest: Namespace attribute the chosen subcommand is stored under.
metavar: Placeholder shown in the usage line, e.g. ``"<command>"``.
required: Whether a subcommand must be supplied.

Returns:
The subparsers action to register subcommands on.
"""
# title=SUPPRESS drops the outer "positional arguments:" heading, since the
# per-group headers act as the section titles. argparse appends the group it
# creates, which would put the subcommands below the options — so hoist it
# above the (now-empty) default positional and optional groups.
subparsers = parser.add_subparsers(
title=SUPPRESS, dest=dest, required=required, metavar=metavar
)
parser._action_groups.insert(0, parser._action_groups.pop())
return subparsers


def group_subcommands(
subparsers: "_SubParsersAction[ArgumentParser]",
group_by_command: dict[str, str],
group_order: list[str],
fallback_group: str = OTHER_GROUP,
) -> None:
"""Reorder a subparsers action's help entries into titled sections.

Call once, after every subparser has been registered. The parser must use
``GroupedHelpFormatter`` or ``GroupedRawTextHelpFormatter`` for the headers
to render correctly. Parsing behaviour is untouched — only the ``--help``
listing changes.

Args:
subparsers: The subparsers action the subcommands were registered on.
group_by_command: Maps subcommand name to the section it belongs under.
group_order: The section titles, in display order. Sections with no
members are skipped.
fallback_group: Section for subcommands missing from
``group_by_command``, or whose group is absent from ``group_order``.

Returns:
None. ``subparsers`` is modified in place.
"""
entries = list(subparsers._choices_actions)

grouped: list[Action] = []
for title in group_order:
members = [
entry for entry in entries if group_by_command.get(entry.dest, fallback_group) == title
]
if not members:
continue
grouped.append(_GroupHeaderAction(title))
grouped.extend(members)

# A subcommand whose group is missing from group_order would otherwise be
# dropped from the listing, so surface it rather than hiding a working
# subcommand behind a typo.
ungrouped = [entry for entry in entries if entry not in grouped]
if ungrouped:
grouped.append(_GroupHeaderAction(fallback_group))
grouped.extend(ungrouped)

subparsers._choices_actions[:] = grouped
Loading
Loading