diff --git a/docs/docs/reference/cli.md b/docs/docs/reference/cli.md index 6992cfc8..6e98c00b 100644 --- a/docs/docs/reference/cli.md +++ b/docs/docs/reference/cli.md @@ -18,6 +18,15 @@ To see all available commands and options: poly --help ~~~ +Commands are listed under section headers so related ones stay together: + +| Section | Commands | +|---|---| +| Getting started | `init`, `start`, `login`, `studio`, `project` | +| Project sync | `pull`, `push`, `status`, `revert`, `format`, `validate`, `diff`, `review`, `branch`, `test`, `rtc`, `chat` | +| Builder API | `deployments`, `conversations`, `audio-cache`, `functions` | +| Other | `docs`, `completion` | + Each command also supports its own help output. For example: ~~~bash @@ -93,6 +102,940 @@ Most commands accept the same four flags: ADK commands are expected to be run from within your local project directory. If needed, use `--path` to point to a project explicitly. +`poly login`: + +1. Prompts for a region if `--region` is not supplied. +2. Opens a browser window for sign-in via the Auth0 device authorization flow. +3. Fetches or creates an API key for your user and saves it to `~/.poly/credentials.json` under the chosen region. + +Run `poly login` once per region you need access to — credentials for multiple regions are stored side by side in the credential file. + +Examples: + +~~~bash +poly login +poly login --region us-1 +poly login --region euw-1 +poly login --region uk-1 +poly login --region studio +~~~ + +| Flag | Description | +|---|---| +| `--region` | Region to log in to. If omitted, you are prompted to pick one. Choices match the standard region list. | + +### `poly project` + +Manage Agent Studio projects. + +#### `poly project create` + +Create a new Agent Studio project under an account, then initialize it locally. + +Run with no arguments and `poly project create` walks you through interactive prompts: + +1. **Region** — auto-selected if your API key only has access to one. +2. **Account** — auto-selected if there's only one in the region; otherwise pick from a searchable list. +3. **Project name** — free-text name for the new project. +4. **Project ID** — optional slug. Defaults to a slugified version of the name (lowercased, spaces replaced with hyphens, special characters removed). Leave empty to let the platform generate one. + +After the project is created in Agent Studio, `poly project create` automatically calls `poly init` to initialize the local project directory. + +Examples: + +~~~bash +poly project create +poly project create --region us-1 --account_id my-account --name my-project +poly project create --region us-1 --account_id my-account --name "My Project" --id my-project +poly project create --region us-1 --account_id my-account --name my-project --greeting "Hi, how can I help?" +poly project create --base-path /path/to/projects +~~~ + +| Flag | Description | +|---|---| +| `--region` | Region for the new project. Choices match the standard region list. | +| `--account_id` | Account ID to create the project under. | +| `--name` | Display name for the new project. | +| `--id`, `--project_id` | Optional slug/ID for the project. Defaults to a slugified version of the name. | +| `--greeting` | Initial greeting message for the agent. Defaults to `"Hello, how can I help you?"`. | +| `--voice-id` | Voice ID for the agent. Defaults to a region-specific voice if not supplied. | +| `--base-path` | Base path to initialize the project in. Defaults to the current working directory. | +| `--json` | Print a single JSON object on stdout (machine-readable). Requires `--region`, `--account_id`, and `--name`. | + +!!! info "`--json` requires explicit flags for `poly project create`" + + When using `poly project create --json`, you must supply `--region`, `--account_id`, and `--name` explicitly. Interactive prompts are not supported in JSON mode. + +#### Error handling + +| Situation | Behaviour | +|---|---| +| `--json` used without `--region`, `--account_id`, or `--name` | Exits with `{ "success": false, "error": "..." }` | +| No accessible regions found | Exits with an error | +| No accounts found in the selected region | Exits with an error | +| API error during project creation | Exits with an error; local init is not attempted | +| No project ID returned by the API | Exits with an error; local init is not attempted | + +### `poly init` + +Initialize a new Agent Studio project locally. + +Run with no arguments and `poly init` walks you through interactive dropdowns: + +1. **Region** — auto-selected if your API key only has access to one. +2. **Account** — auto-selected if there's only one in the region; otherwise pick from a searchable list. Each entry is shown as `"name (id)"` to disambiguate accounts that share the same display name. +3. **Project** — pick from a searchable list of every project the API key can see. Each entry is shown as `"name (id)"` for the same reason. + +If no projects are found in the selected account, `poly init` offers to create one. Accepting the prompt starts the [`poly project create`](#poly-project-create) flow with the region and account already pre-selected. + +After selection, `poly init` creates the project directory at `{base_path}/{account_id}/{project_id}` and immediately pulls the current configuration from Agent Studio. Change into the project directory before running any other commands. + +The human-readable project name is stored in `project.yaml` alongside the `project_id`, `account_id`, and `region`: + +~~~yaml +project_id: my-project +account_id: my-workspace +region: us-1 +project_name: My Project +~~~ + +Pass any combination of `--region`, `--account_id`, and `--project_id` to skip the matching prompt. This is the form to use in scripts and CI. + +Examples: + +~~~bash +poly init +poly init --account_id 123 --project_id my_project +poly init --region us-1 --account_id 123 --project_id my_project +poly init --base-path /path/to/projects +poly init --format +~~~ + +#### Error handling + +If the account or project ID is invalid or inaccessible, `poly init` returns a descriptive error and cleans up any partially created directories so no empty folders are left behind. + +| Situation | Error message | +|---|---| +| `POLY_ADK_KEY` not set | `POLY_ADK_KEY environment variable is not set. Export your API key with: export POLY_ADK_KEY=` | +| No accounts found in the region | `No accounts found in the selected region.` | +| No projects found in the account | Prompts to create a new project (interactive) or exits with error (JSON mode). | +| Project not found | `Project '' not found in account ''.` | +| Permission denied | `Forbidden: you do not have permission to access project '' in account ''.` | + +When using `--json`, the response includes `{ "success": false, "error": "..." }` with the same message. + +### `poly pull` + +Pull the latest project configuration from Agent Studio. + +Examples: + +~~~bash +poly pull +poly pull --force +poly pull --format +~~~ + +If the branch you are currently on no longer exists in Agent Studio, `poly pull` automatically switches to the `main` branch and displays a warning message with the new branch name. + +When using JSON output (`--json`), the response includes `new_branch_name` and `new_branch_id` fields if a branch switch occurred. + +### `poly push` + +Push local changes to Agent Studio. + +Examples: + +~~~bash +poly push +poly push --dry-run +poly push --skip-validation +poly push --force +poly push --format +~~~ + +When pushing creates a new branch (for example, when pushing to Agent Studio for the first time on a branch), the CLI displays a message with the new branch name. + +| Flag | Description | +|---|---| +| `--dry-run` | Run all validation and diff steps without sending changes to Agent Studio. | +| `--skip-validation` | Bypass local validation. Use sparingly — for example, when a platform-generated resource fails a strict ADK check but is known to be valid on the platform. | +| `--force`, `-f` | Force the push even when the local project diverges from the remote in unexpected ways. | +| `--format` | Run [`poly format`](#poly-format) over the project before pushing. | + +!!! info "Call Link URL in chat output may be malformed" + + Each chat session prints a Call Link URL for viewing the conversation in Agent Studio. On some deployments this URL has a doubled hostname (for example, `https://studio.studio.poly.ai/…`), which produces a 404. The conversation is still recorded — open Agent Studio directly and navigate to the conversation from there. + +!!! info "`poly push` reports an error message when there is nothing to push" + + If there are no local changes, `poly push` prints `Error: Failed to push` and `No changes detected`. The exit code is 0, so CI scripts that check return codes are not affected. The message is misleading but the command has not actually failed. + +When using JSON output (`--json`), the response includes `new_branch_name` and `new_branch_id` fields if a new branch was created. + +### `poly status` + +View changed, new, and deleted files in your project. + +~~~bash +poly status +~~~ + +### `poly diff` + +Show differences between the local project and the remote version. + +Examples: + +~~~bash +poly diff +poly diff --files file1.yaml +poly diff --before main --after my-feature +~~~ + +### `poly revert` + +Revert local changes. + +Examples: + +~~~bash +poly revert +poly revert file1.yaml file2.yaml +~~~ + +`poly revert` with no arguments reverts every change in the working tree; pass file paths to revert only those files. + +### `poly branch` + +Manage project branches. `poly branch --help` splits its subcommands into +**Branch lifecycle** (`list`, `create`, `switch`, `current`, `delete`, `merge`) +and **Inspect** (`diff`, `review`, `status`). + +Examples: + +~~~bash +poly branch list +poly branch current +poly branch create my-feature +poly branch create my-hotfix --env live +poly branch create my-hotfix --env live --force +poly branch switch my-feature +poly branch switch my-feature --force +poly branch merge 'Merge feature branch' +poly branch merge 'Merge feature branch' --interactive +poly branch delete +poly branch delete my-feature +~~~ + +#### `poly branch merge` + +Merge the current branch into `main` via the CLI. A merge message is required. + +~~~bash +poly branch merge 'Merge message' +poly branch merge 'Merge message' --interactive +poly branch merge 'Merge message' --resolutions resolutions.json +~~~ + +For the full merge workflow — conflict tables, `--interactive` flow, the `--resolutions` JSON format, post-merge behavior, and troubleshooting — see the dedicated [Branch merging reference](./branch_merge.md). + +#### `poly branch delete` + +Interactively select and delete one or more branches. The `main` branch cannot be deleted. + +- Run without arguments to open an interactive checkbox prompt for selecting branches to delete. +- Pass a branch name directly to skip the interactive prompt and delete that branch after confirmation. + +~~~bash +poly branch delete +poly branch delete my-feature +~~~ + +!!! warning "`poly branch delete` requires a TTY and may fail with a 404" + + `poly branch delete` opens an interactive confirmation prompt and must be run in a terminal. In non-interactive environments (scripts, CI), it throws `[Errno 22] Invalid argument`. + + On some projects, the delete command hits the same platform endpoint as branch chat and returns a 404 after the confirmation. If this happens, delete the branch through the Agent Studio UI instead. + +#### `poly branch create` + +Creates a new branch. By default the branch is sourced from the project's `main` branch (the sandbox environment). + +| Flag | Description | +|---|---| +| `--env`, `--environment` | Source the new branch from a deployed environment snapshot instead of `main`. Choices: `sandbox`, `pre-release`, `live`. | +| `--force`, `-f` | Force branch creation even if there are uncommitted local changes on main. | + +When `--env live` or `--env pre-release` is specified: + +- the version of the deployed environment is pulled into your local workspace +- a branch is created from that snapshot +- the version is immediately pushed to the new branch, leaving a clean slate for hotfix changes +- the command can only be run from `main` +- if there are local changes, the command will fail unless `--force` is also passed + +!!! warning "Use `--env live` with caution" + + Branching from a live deployment snapshot will overwrite your local project with the live state. Merging this branch back to main may roll back changes that were introduced after the snapshot was taken. + +!!! info "Only one active branch is allowed at a time" + + Agent Studio supports one non-main branch per project. Attempting to create a second branch while one already exists returns an error. Merge or delete the existing branch in Agent Studio before creating a new one. + +### `poly format` + +Format project resources. + +Examples: + +~~~bash +poly format +poly format --check +poly format --files src/functions/booking.py +~~~ + +### `poly validate` + +Validate project configuration locally. + +~~~bash +poly validate +~~~ + +### `poly review` + +Create a GitHub Gist of Agent Studio project changes to share with others. + +`poly review` requires a subcommand: `create`, `list`, or `delete`. Use `poly review create` to compare your local changes against the remote project, or pass `--before` and `--after` to compare two remote branches or versions. Add `--verbose` for full error tracebacks while troubleshooting. + +Examples: + +~~~bash +poly review create +poly review create --before main --after feature-branch +poly review create --verbose +~~~ + +#### `poly review list` + +Interactively select a review gist and open it in the browser. + +~~~bash +poly review list +poly review list --json +~~~ + +#### `poly review delete` + +Interactively select and delete review gists. Use `--id` to delete a specific gist directly without an interactive prompt. + +~~~bash +poly review delete +poly review delete --id GIST_ID +poly review delete --json +~~~ + +### `poly chat` + +Start an interactive chat session with your agent, or run scripted/automated conversations. + +Examples: + +~~~bash +poly chat +poly chat --environment live +poly chat --channel webchat +poly chat --sip-header X-Customer-ID=12345 +poly chat --sip-header X-Customer-ID=12345 --sip-header X-Language=en-GB +poly chat --metadata +poly chat --lang fr-FR +poly chat --input-lang en-US --output-lang fr-FR +~~~ + +#### Non-interactive (scripted) mode + +Supply messages directly on the command line or from a file to run `poly chat` without a human at the terminal. This is useful for automated testing pipelines and CI scripts. + +**Inline messages** — use `-m`/`--message` (repeatable): + +~~~bash +poly chat -m 'Hello' -m 'What can you help with?' +~~~ + +**File-based input** — use `--input-file`: + +~~~bash +poly chat --input-file ./script.txt +echo -e 'Hello\nGoodbye' | poly chat --input-file - +~~~ + +Each line of the file is sent as a separate message. Use `-` to read from stdin. + +If the file path does not exist, `poly chat` exits with an error. + +#### Resuming an existing conversation + +Use `--conversation-id` (or `--conv-id`) to resume an existing conversation by its ID instead of creating a new session: + +~~~bash +poly chat --conv-id +poly chat --conv-id -m 'Follow-up message' +~~~ + +#### Pushing before chatting + +Use `--push` to push the local project to Agent Studio before starting the chat session. This ensures local changes are live before testing without requiring a separate `poly push` step: + +~~~bash +poly chat --push +poly chat --push -m 'Hello' +~~~ + +If the push fails, the command exits without starting the chat session. + +#### Language flags + +Use language flags to specify the expected input and output language when chatting against multilingual agents. If not specified, the project default is used. + +| Flag | Description | +|---|---| +| `--lang` | Sets both input and output language (e.g. `en-US`, `fr-FR`). | +| `--input-lang` | Sets the input language (ASR) only. Overrides `--lang` for input. | +| `--output-lang` | Sets the output language (TTS) only. Overrides `--lang` for output. | + +`--input-lang` and `--output-lang` take precedence over `--lang` when both are supplied. + +#### Simulating SIP headers + +Use `--sip-header NAME=VALUE` to simulate a SIP header when starting a conversation. + +Single header: + +~~~bash +poly chat --sip-header X-Customer-ID=12345 +~~~ + +Multiple headers are supported by repeating the flag: + +~~~bash +poly chat --sip-header X-Customer-ID=12345 --sip-header X-Language=en-GB +~~~ + +The values are exposed to project functions through `conv.sip_headers`. This is for +testing agent behaviour only; it does not create a SIP call or reproduce carrier-level +SIP behaviour. SIP headers cannot be changed when resuming an existing conversation. + +#### `poly chat` flags summary + +| Flag | Description | +|---|---| +| `--push` | Push the project before starting the chat session. | +| `-m`, `--message MSG` | Send a message non-interactively (repeatable). | +| `--input-file FILE` | Read messages line-by-line from a file (`-` for stdin). | +| `--conversation-id`, `--conv-id` | Resume an existing conversation by ID. | +| `--json` | Emit a single JSON object when the session ends (see below). | +| `--environment` | Target environment. Choices: `branch`, `sandbox`, `pre-release`, `live`. Defaults to `branch`. `branch` chats against the last **pushed** state of your current branch (not local uncommitted changes); on main it falls back to `sandbox`. Use `--push` to push local changes before chatting. | +| `--channel` | Channel to use (e.g. `webchat`, `voice`). | +| `--sip-header NAME=VALUE` | Simulate a SIP header at conversation start (repeatable). | +| `--lang` | Set both input and output language. | +| `--input-lang` | Set input language only. | +| `--output-lang` | Set output language only. | +| `--variant` | Name of the variant to use for the chat session. | +| `--functions` | Show function events in output. | +| `--flows` | Show flow metadata in output. | +| `--state` | Show state changes in output. | +| `--metadata` | Show all metadata (equivalent to `--functions --flows --state`). | + +### `poly conversations` + +List and inspect conversations for the project using the public Conversations API. + +`poly conversations` requires a subcommand: `list`, `get`, or `get-audio`. + +Examples: + +~~~bash +poly conversations list +poly conversations get +poly conversations get-audio -o recording.wav +~~~ + +#### `poly conversations list` + +List conversations for the project. + +~~~bash +poly conversations list +poly conversations list --limit 20 --offset 10 +poly conversations list --json +~~~ + +| Flag | Description | +|---|---| +| `--limit` | Max number of conversations to return. Defaults to `50`. | +| `--offset` | Number of conversations to skip. Defaults to `0`. | +| `--path` | Base path to the project. Defaults to the current working directory. | +| `--json` | Print a single JSON object on stdout (machine-readable). | + +The default table view shows conversation ID (rendered as a clickable Agent Studio link), start time, duration, caller number, channel, variant (when present), handoff status, and a short summary heading. + +#### `poly conversations get` + +Get detailed information for a specific conversation, including all turns. + +~~~bash +poly conversations get +poly conversations get --json +~~~ + +| Argument / Flag | Description | +|---|---| +| `conversation_id` | The conversation ID to look up. Required. | +| `--path` | Base path to the project. Defaults to the current working directory. | +| `--json` | Print a single JSON object on stdout (machine-readable). | + +The default output shows conversation metadata (channel, language, duration, timestamps, handoff, tags, PolyScore, summary, note) followed by a turn-by-turn transcript. + +#### `poly conversations get-audio` + +Download the audio recording for a conversation as a WAV file. + +~~~bash +poly conversations get-audio +poly conversations get-audio --direction user +poly conversations get-audio --redacted -o redacted.wav +poly conversations get-audio --json +~~~ + +| Argument / Flag | Description | +|---|---| +| `conversation_id` | The conversation ID. Required. | +| `--direction` | Audio track to download. Choices: `combined`, `user`, `agent`. Defaults to `combined`. | +| `--redacted` | Download the redacted version of the audio. | +| `-o`, `--output` | Output file path. Defaults to `.wav`. | +| `--path` | Base path to the project. Defaults to the current working directory. | +| `--json` | Print a JSON summary on stdout instead of the success message (audio is still written to disk). | + +### `poly audio-cache` + +Manage an agent's cached TTS audio entries using the public Audio Cache API. + +`poly audio-cache` requires a subcommand: `list`, `get-file`, `update-file`, `update-details`, `delete`, `bulk-delete`, or `synthesize`. + +Examples: + +~~~bash +poly audio-cache list +poly audio-cache get-file -o cached.wav +poly audio-cache update-file --file replacement.wav +poly audio-cache synthesize --text "Hello there" -o preview.wav +poly audio-cache delete +poly audio-cache bulk-delete --ids id1,id2,id3 +~~~ + +#### `poly audio-cache list` + +List cached TTS audio entries with metadata (transcript, provider, voice, duration, hit count). + +~~~bash +poly audio-cache list +poly audio-cache list --limit 20 --offset 10 +poly audio-cache list --sort hit_count:desc +~~~ + +| Flag | Description | +|---|---| +| `--limit` | Max number of entries to return (1-200). Defaults to `50`. | +| `--offset` | Number of entries to skip. Defaults to `0`. | +| `--sort` | Sort expression, e.g. `hit_count:desc` or `duration:asc`. | +| `--path` | Base path to the project. Defaults to the current working directory. | +| `--json` | Print a single JSON object on stdout (machine-readable). | + +#### `poly audio-cache get-file` + +Download the cached WAV audio file for a cache entry. + +~~~bash +poly audio-cache get-file +poly audio-cache get-file -o cached.wav +~~~ + +| Argument / Flag | Description | +|---|---| +| `entry_id` | The audio cache entry ID. Required. | +| `-o`, `--output` | Output file path. Defaults to `.wav`. | +| `--path` | Base path to the project. Defaults to the current working directory. | +| `--json` | Print a JSON summary on stdout instead of the success message (audio is still written to disk). | + +#### `poly audio-cache update-file` + +Replace the audio file for an existing cache entry. Maximum file size is 6MB. + +~~~bash +poly audio-cache update-file --file replacement.wav +poly audio-cache update-file --file replacement.wav --filename clip.wav +~~~ + +| Argument / Flag | Description | +|---|---| +| `entry_id` | The audio cache entry ID. Required. | +| `--file` | Path to the local replacement WAV file. Required. | +| `--filename` | Filename to record for the uploaded audio. Defaults to the local file's basename. | +| `--path` | Base path to the project. Defaults to the current working directory. | +| `--json` | Print a single JSON object on stdout (machine-readable). | + +#### `poly audio-cache update-details` + +Replace both the audio file and voice tuning settings for a cache entry in one call. Maximum file size is 6MB. + +~~~bash +poly audio-cache update-details --file replacement.wav --text "Hi there" +poly audio-cache update-details --file replacement.wav --text "Hi" --config '{"stability": 0.5}' +~~~ + +| Argument / Flag | Description | +|---|---| +| `entry_id` | The audio cache entry ID. Required. | +| `--file` | Path to the local replacement WAV file. Required. | +| `--text` | Transcript text associated with the audio. Required. | +| `--config` | JSON object of provider-specific voice tuning settings (e.g. `stability`, `speed`, `model_id`). Defaults to `{}`. | +| `--path` | Base path to the project. Defaults to the current working directory. | +| `--json` | Print a single JSON object on stdout (machine-readable). | + +#### `poly audio-cache delete` + +Permanently delete a cached audio entry and its audio file. + +~~~bash +poly audio-cache delete +~~~ + +| Argument / Flag | Description | +|---|---| +| `entry_id` | The audio cache entry ID. Required. | +| `--path` | Base path to the project. Defaults to the current working directory. | +| `--json` | Print a single JSON object on stdout (machine-readable). | + +#### `poly audio-cache bulk-delete` + +Delete multiple audio cache entries in a single request. Best-effort — reports which IDs succeeded and which failed, so partial failures can be retried. Maximum 20 IDs per request. + +~~~bash +poly audio-cache bulk-delete --ids id1,id2,id3 +~~~ + +| Argument / Flag | Description | +|---|---| +| `--ids` | Comma-separated list of audio cache entry IDs to delete (max 20). Required. | +| `--path` | Base path to the project. Defaults to the current working directory. | +| `--json` | Print a single JSON object on stdout (machine-readable). | + +#### `poly audio-cache synthesize` + +Generate a TTS audio preview using an existing cache entry's voice and provider configuration, without saving it to the cache. + +~~~bash +poly audio-cache synthesize --text "Hello there" +poly audio-cache synthesize --text "Hi" --language en-US -o out.wav +~~~ + +| Argument / Flag | Description | +|---|---| +| `entry_id` | The audio cache entry ID whose voice/provider config to preview with. Required. | +| `--text` | Text to synthesize. Required. | +| `--config` | JSON object of provider-specific voice tuning settings. Defaults to `{}`. | +| `--language` | BCP-47 language tag, e.g. `en-US`. | +| `-o`, `--output` | Output file path. Defaults to `-preview.wav`. | +| `--path` | Base path to the project. Defaults to the current working directory. | +| `--json` | Print a JSON summary on stdout instead of the success message (audio is still written to disk). | + +### `poly functions` + +Run, inspect and deploy Functions using the public Functions REST API, scoped to the project's current branch. + +!!! note + + Creating, editing and deleting Functions is done via the local `functions/*.py` files synced by `poly pull` / `poly push` (see [Functions](functions.md)) — that mechanism already covers CRUD, including flow-scoped transition functions, function steps and latency control, and keeps changes reviewable in a branch diff before they're pushed. `poly functions` is additive: it covers what push/pull can't — running a function, checking its deployment history, validating it, and inspecting its references — via direct REST calls that don't touch local files. + +Every subcommand also accepts `--region`, `--project_id` and `--branch_id` directly, so `poly functions` can run headlessly (CI, scripts) without a local project checkout: + +~~~bash +poly functions execute --region us-1 --project_id abc123 --branch_id main +~~~ + +All three must be given together — if any one is set, all three are required. With none set, the current local project's region/project/branch are used, as before. + +`poly functions --help` splits its subcommands into two sections: + +| Section | Subcommands | +|---|---| +| Run and inspect | `execute`, `references`, `type-definitions` | +| Deploy | `validate`, `deploy`, `deployments` | + +Examples: + +~~~bash +poly functions execute --args '{"x": 1}' +poly functions references +poly functions deploy +~~~ + +#### `poly functions execute` + +Execute a Function and print its return value, logs and runtime. + +~~~bash +poly functions execute +poly functions execute --args '{"x": 1}' +~~~ + +| Argument / Flag | Description | +|---|---| +| `function_id` | The function ID. Required. | +| `--args` | JSON object of arguments to pass to the function. Defaults to `{}`. | +| `--path` | Base path to the project. Defaults to the current working directory. | +| `--json` | Print a single JSON object on stdout (machine-readable). | + +#### `poly functions deploy` + +Deploy every draft Function on the current branch. + +~~~bash +poly functions deploy +~~~ + +| Flag | Description | +|---|---| +| `--path` | Base path to the project. Defaults to the current working directory. | +| `--json` | Print a single JSON object on stdout (machine-readable). | + +#### `poly functions validate` + +Check every Function on the current branch for syntax errors and orphaned flow-step references. + +~~~bash +poly functions validate +~~~ + +| Flag | Description | +|---|---| +| `--path` | Base path to the project. Defaults to the current working directory. | +| `--json` | Print a single JSON object on stdout (machine-readable). | + +#### `poly functions references` + +List the flow steps that call a Function. Useful before a rename or delete. + +~~~bash +poly functions references +~~~ + +| Argument / Flag | Description | +|---|---| +| `function_id` | The function ID. Required. | +| `--path` | Base path to the project. Defaults to the current working directory. | +| `--json` | Print a single JSON object on stdout (machine-readable). | + +#### `poly functions type-definitions` + +Print the `Conversation`/`Flow` type stubs available to a Function, for IDE autocomplete. + +~~~bash +poly functions type-definitions +poly functions type-definitions > stubs.py +~~~ + +| Argument / Flag | Description | +|---|---| +| `function_id` | The function ID. Required. | +| `--path` | Base path to the project. Defaults to the current working directory. | +| `--json` | Print a single JSON object on stdout (machine-readable). | + +#### `poly functions deployments` + +Show the deployment history for the project's Functions across environments. + +~~~bash +poly functions deployments +~~~ + +| Flag | Description | +|---|---| +| `--path` | Base path to the project. Defaults to the current working directory. | +| `--json` | Print a single JSON object on stdout (machine-readable). | + +### `poly docs` + +Output resource documentation. + +Examples: + +~~~bash +poly docs flows functions topics +poly docs context +poly docs --all +poly docs --all --output rules.md +~~~ + +Use `--output` to write the documentation to a local file. This is useful when working with AI coding tools — pass the output file as context to give the agent accurate knowledge of ADK resource types and conventions. + +Available resource names include: + +| Name | Description | +|---|---| +| `agent_settings` | Personality, role, rules | +| `api_integrations` | External HTTP API definitions | +| `chat_settings` | Chat greeting, style prompt | +| `context` | Context files for agent knowledge | +| `entities` | Structured data collection | +| `experimental_config` | Feature flags | +| `flows` | Multistep processes with steps, functions, conditions | +| `handoffs` | SIP call transfers | +| `functions` | Global and flow functions, decorators, state, metrics | +| `languages` | Default and additional language configuration | +| `tests` | Simulated conversation test cases | +| `safety_filters` | Content moderation settings | +| `sms` | Text message templates | +| `speech_recognition` | ASR settings, keyphrase boosting, transcript corrections | +| `response_control` | Pronunciations, phrase filters | +| `topics` | Knowledge base for RAG | +| `translations` | Localized text strings per language | +| `variants` | Per-variant configuration | +| `voice_settings` | Voice greeting, disclaimer, style prompt | +| `variables` | State variables referenced in code | + +### `poly deployments` + +Manage deployments for the project. + +#### `poly deployments list` + +List deployments for the project. + +Examples: + +~~~bash +poly deployments list +poly deployments list --env live +poly deployments list --details +poly deployments show abc123def +poly deployments show abc123def --env live +~~~ + +#### `poly deployments list` + +List deployments for the project. + +| Flag | Description | +|---|---| +| `--env` | Environment to list deployments for. Choices: `sandbox`, `pre-release`, `live`. Defaults to `sandbox`. | +| `--details` | Show additional deployment details. | +| `--verbose` | Show full error tracebacks for debugging. | + +!!! tip "Use `--details` for readable output" + + The default tabular view may wrap long URLs across multiple rows, making it unreadable in narrow terminals. `--details` produces a vertical layout that is easier to read. + +#### `poly deployments promote` + +Promote a deployment to the next environment (`pre-release` or `live`), removing the need to use the Agent Studio UI. + +Examples: + +~~~bash +poly deployments promote --from --to pre-release +poly deployments promote --from sandbox --to live --message "Release notes here" +poly deployments promote --from --to pre-release --dry-run +poly deployments promote --from --to live --force +~~~ + +| Flag | Description | +|---|---| +| `--from` | ID or environment name of the deployment to promote. Required. | +| `--to` | Target environment. Choices: `pre-release`, `live`. Required. | +| `--message`, `-m` | Optional message to include with the promotion (e.g. release notes or changelog). If not specified, the existing deployment message is used. | +| `--force` | Skip the confirmation prompt. When used without `--message`, the existing deployment message is kept. This is the default in non-interactive mode (e.g. when `--json` is used). | +| `--dry-run` | Show what would be promoted without actually promoting. Displays the deployment hash, target environment, and changes included. | +| `--verbose` | Show full error tracebacks for debugging. | + +When promoting to `live`, the command searches for the deployment in `pre-release` and uses sandbox as the linear history source for computing included changes. When promoting to `pre-release`, the command searches sandbox. + +The output includes: + +- the deployment hash being promoted +- whether it is a first-time promotion to that environment +- a list of **included deployments** (changes being promoted) or **reverting deployments** (when promoting to an older version) + +Without `--force`, the command prompts for confirmation before proceeding and optionally allows you to enter or override the deployment message interactively. + +#### `poly deployments rollback` + +Roll back sandbox to a previous deployment version. + +Examples: + +~~~bash +poly deployments rollback --to +poly deployments rollback --to --message "Rolling back due to regression" +poly deployments rollback --to --dry-run +poly deployments rollback --to --force +~~~ + +| Flag | Description | +|---|---| +| `--to` | ID or environment name of the deployment to roll back to. Required. | +| `--message`, `-m` | Optional message to include with the rollback. If not specified, the existing deployment message is used. | +| `--force` | Skip the confirmation prompt. This is the default in non-interactive mode (e.g. when `--json` is used). | +| `--dry-run` | Show what would be rolled back without actually rolling back. Displays the target deployment and the deployments that would be reverted. | +| `--verbose` | Show full error tracebacks for debugging. | + +The output includes a list of **reverting deployments** — the versions that will be undone when the rollback completes. + +Without `--force`, the command prompts for confirmation before proceeding. + +## Machine-readable JSON output + +All core subcommands accept a `--json` flag that switches stdout to a single JSON object. This is designed for scripting, CI pipelines, and any integration that needs stable, parseable output rather than human-readable console text. + +~~~bash +poly status --json +poly push --json +poly pull --json +poly validate --json +poly diff --json +poly revert --json +poly branch list --json +poly branch create my-feature --json +poly branch switch my-feature --json +poly branch current --json +poly branch delete --json +poly branch delete my-feature --json +poly branch merge 'Merge message' --json +poly format --json +poly init --region us-1 --account_id 123 --project_id my_project --json +poly project create --region us-1 --account_id my-account --name my-project --json +poly chat --json -m 'Hello' +poly chat --json --input-file ./script.txt +poly deployments show abc123def --json +poly deployments list --json +poly deployments promote --from --to pre-release --force --json +poly deployments rollback --to --force --json +poly conversations list --json +poly conversations get --json +poly conversations get-audio --json +poly audio-cache list --json +poly audio-cache get-file --json +poly audio-cache update-file --file replacement.wav --json +poly audio-cache delete --json +poly audio-cache bulk-delete --ids id1,id2 --json +poly audio-cache synthesize --text "Hello" --json +poly functions execute --args '{"x": 1}' --json +poly functions validate --json +poly functions deploy --json +~~~ + ### `--json` contract When `--json` is used: diff --git a/docs/docs/reference/resources/functions.md b/docs/docs/reference/resources/functions.md index f98261aa..d2165c40 100644 --- a/docs/docs/reference/resources/functions.md +++ b/docs/docs/reference/resources/functions.md @@ -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 diff --git a/src/poly/cli.py b/src/poly/cli.py index 02c436ec..6d2737b0 100644 --- a/src/poly/cli.py +++ b/src/poly/cli.py @@ -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 @@ -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__) @@ -57,6 +66,7 @@ DeploymentsCommand, ConversationsCommand, AudioCacheCommand, + FunctionsCommand, TestingCommand, RTCCommand, ChatCommand, @@ -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", @@ -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="") 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): diff --git a/src/poly/cli_commands/audio_cache.py b/src/poly/cli_commands/audio_cache.py index 890fa5c3..5577642d 100644 --- a/src/poly/cli_commands/audio_cache.py +++ b/src/poly/cli_commands/audio_cache.py @@ -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 @@ -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.""" diff --git a/src/poly/cli_commands/auth.py b/src/poly/cli_commands/auth.py index 6471369c..0eb0c42d 100644 --- a/src/poly/cli_commands/auth.py +++ b/src/poly/cli_commands/auth.py @@ -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 @@ -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.""" @@ -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.""" diff --git a/src/poly/cli_commands/base.py b/src/poly/cli_commands/base.py index 9ab0b145..84736ee1 100644 --- a/src/poly/cli_commands/base.py +++ b/src/poly/cli_commands/base.py @@ -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: @@ -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 @@ -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 ```` + 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. ``""``. + 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 diff --git a/src/poly/cli_commands/branch.py b/src/poly/cli_commands/branch.py index 89c881e1..d08c204b 100644 --- a/src/poly/cli_commands/branch.py +++ b/src/poly/cli_commands/branch.py @@ -7,12 +7,19 @@ import os import subprocess import sys -from argparse import SUPPRESS, ArgumentParser, Namespace, RawTextHelpFormatter, _SubParsersAction +from argparse import SUPPRESS, ArgumentParser, Namespace, _SubParsersAction from collections import Counter from contextlib import nullcontext from typing import Any, Optional -from poly.cli_commands.base import BaseCommand, Parents +from poly.cli_commands.base import ( + PROJECT_SYNC_GROUP, + BaseCommand, + GroupedRawTextHelpFormatter, + Parents, + add_grouped_subparsers, + group_subcommands, +) from poly.cli_commands.shared import load_project, parse_from_projection_json, read_project_config from poly.output.json_output import json_print from poly.project import DeploymentMode @@ -22,6 +29,31 @@ # Single-line values longer than this are treated like multiline (no terminal dump; editor for edit). _BRANCH_MERGE_LONG_LINE_THRESHOLD = 800 +# Section headers for `poly branch --help`: commands that move a branch through +# its lifecycle, then the read-only ones that report on it. +BRANCH_LIFECYCLE_GROUP = "Branch lifecycle" +BRANCH_INSPECT_GROUP = "Inspect" + +BRANCH_SUBCOMMAND_GROUP_ORDER = [BRANCH_LIFECYCLE_GROUP, BRANCH_INSPECT_GROUP] + +BRANCH_SUBCOMMAND_GROUPS = { + "list": BRANCH_LIFECYCLE_GROUP, + "create": BRANCH_LIFECYCLE_GROUP, + "switch": BRANCH_LIFECYCLE_GROUP, + "current": BRANCH_LIFECYCLE_GROUP, + "rename": BRANCH_LIFECYCLE_GROUP, + "delete": BRANCH_LIFECYCLE_GROUP, + "restore": BRANCH_LIFECYCLE_GROUP, + "merge": BRANCH_LIFECYCLE_GROUP, + "sync": BRANCH_LIFECYCLE_GROUP, + "tag": BRANCH_LIFECYCLE_GROUP, + "untag": BRANCH_LIFECYCLE_GROUP, + "diff": BRANCH_INSPECT_GROUP, + "review": BRANCH_INSPECT_GROUP, + "status": BRANCH_INSPECT_GROUP, + "history": BRANCH_INSPECT_GROUP, +} + def _branch_merge_conflict_file_key(path: list[str]) -> str: """Group field-level API conflicts by parent path (resource-ish key).""" @@ -109,6 +141,8 @@ class BranchCommand(BaseCommand): command = "branch" + group = PROJECT_SYNC_GROUP + @classmethod def _branch_name_completer( cls, @@ -154,9 +188,11 @@ def add_arguments(cls, subparsers: _SubParsersAction[ArgumentParser], parents: P " poly branch tag\n" " poly branch untag\n" ), - formatter_class=RawTextHelpFormatter, + formatter_class=GroupedRawTextHelpFormatter, + ) + branch_subparsers = add_grouped_subparsers( + branches_parser, dest="branch_subcommand", metavar="" ) - branch_subparsers = branches_parser.add_subparsers(dest="branch_subcommand", required=True) # -- list -- branch_list_parser = branch_subparsers.add_parser( @@ -449,6 +485,10 @@ def add_arguments(cls, subparsers: _SubParsersAction[ArgumentParser], parents: P ) branch_history_parser.set_defaults(branch_subcommand="history") + group_subcommands( + branch_subparsers, BRANCH_SUBCOMMAND_GROUPS, BRANCH_SUBCOMMAND_GROUP_ORDER + ) + @classmethod def run(cls, args: Namespace) -> None: """Dispatch to the matching branch sub-handler.""" diff --git a/src/poly/cli_commands/chat.py b/src/poly/cli_commands/chat.py index bf1c08c1..9aef609f 100644 --- a/src/poly/cli_commands/chat.py +++ b/src/poly/cli_commands/chat.py @@ -16,7 +16,7 @@ from contextlib import nullcontext from typing import Optional -from poly.cli_commands.base import BaseCommand, Parents +from poly.cli_commands.base import PROJECT_SYNC_GROUP, BaseCommand, Parents from poly.cli_commands.shared import load_project from poly.output.json_output import json_print from poly.project import AgentStudioProject @@ -38,6 +38,8 @@ class ChatCommand(BaseCommand): command = "chat" + group = PROJECT_SYNC_GROUP + @classmethod def add_arguments(cls, subparsers: _SubParsersAction[ArgumentParser], parents: Parents) -> None: """Register the ``chat`` subcommand.""" diff --git a/src/poly/cli_commands/conversations.py b/src/poly/cli_commands/conversations.py index 52e8de4d..b6c75f2b 100644 --- a/src/poly/cli_commands/conversations.py +++ b/src/poly/cli_commands/conversations.py @@ -6,7 +6,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 @@ -17,6 +17,8 @@ class ConversationsCommand(BaseCommand): command = "conversations" + group = BUILDER_API_GROUP + @classmethod def add_arguments(cls, subparsers: _SubParsersAction[ArgumentParser], parents: Parents) -> None: """Register the ``conversations`` subcommand tree.""" diff --git a/src/poly/cli_commands/deployments.py b/src/poly/cli_commands/deployments.py index 64739e5f..631ffd2d 100644 --- a/src/poly/cli_commands/deployments.py +++ b/src/poly/cli_commands/deployments.py @@ -8,7 +8,7 @@ from argparse import ArgumentParser, Namespace, RawTextHelpFormatter, _SubParsersAction from typing import Any, 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.output.json_output import json_print from poly.project import AgentStudioProject @@ -21,6 +21,8 @@ class DeploymentsCommand(BaseCommand): command = "deployments" + group = BUILDER_API_GROUP + @classmethod def add_arguments(cls, subparsers: _SubParsersAction[ArgumentParser], parents: Parents) -> None: """Register the ``deployments`` subcommand tree.""" diff --git a/src/poly/cli_commands/functions.py b/src/poly/cli_commands/functions.py new file mode 100644 index 00000000..d86e51d5 --- /dev/null +++ b/src/poly/cli_commands/functions.py @@ -0,0 +1,441 @@ +"""Functions command family: manage Functions via the public Functions REST API. + +Scoped to the project's current branch. Distinct from the local-file/decorator +Functions (``poly/resources/function.py``) synced via ``poly push``/``poly pull``. + +Copyright PolyAI Limited +""" + +import json +import sys +from argparse import ArgumentParser, Namespace, RawTextHelpFormatter, _SubParsersAction +from typing import Any, Optional + +from poly.cli_commands.base import ( + BUILDER_API_GROUP, + BaseCommand, + GroupedRawTextHelpFormatter, + Parents, + add_grouped_subparsers, + group_subcommands, +) +from poly.cli_commands.shared import resolve_project_scope +from poly.handlers.interface import AgentStudioInterface +from poly.output.json_output import json_print + +# Section headers for `poly functions --help`: running/inspecting individual +# functions, then deploying them. CRUD, duplication and the start/end +# lifecycle hooks live in the local-file/decorator Functions mechanism (synced +# via push/pull); this command family only covers what that mechanism can't do. +FUNCTIONS_RUN_GROUP = "Run and inspect" +FUNCTIONS_DEPLOY_GROUP = "Deploy" + +FUNCTIONS_SUBCOMMAND_GROUP_ORDER = [ + FUNCTIONS_RUN_GROUP, + FUNCTIONS_DEPLOY_GROUP, +] + +FUNCTIONS_SUBCOMMAND_GROUPS = { + "execute": FUNCTIONS_RUN_GROUP, + "references": FUNCTIONS_RUN_GROUP, + "type-definitions": FUNCTIONS_RUN_GROUP, + "validate": FUNCTIONS_DEPLOY_GROUP, + "deploy": FUNCTIONS_DEPLOY_GROUP, + "deployments": FUNCTIONS_DEPLOY_GROUP, +} + + +class FunctionsCommand(BaseCommand): + """Manage Functions via the public Functions REST API.""" + + command = "functions" + + group = BUILDER_API_GROUP + + @classmethod + def add_arguments(cls, subparsers: _SubParsersAction[ArgumentParser], parents: Parents) -> None: + """Register the ``functions`` subcommand tree.""" + functions_parser = subparsers.add_parser( + "functions", + parents=[parents.verbose], + help="Manage Functions via the public Functions REST API.", + description=( + "Manage Functions via the public Functions REST API, scoped to the\n" + "project's current branch. Creating, editing and deleting functions is\n" + "still done via the local-file/decorator workflow (poly push/poly pull);\n" + "this covers what that workflow can't: running, inspecting and\n" + "deploying functions.\n\n" + "Examples:\n" + " poly functions execute --args '{\"x\": 1}'\n" + " poly functions references \n" + " poly functions deploy\n" + ), + formatter_class=GroupedRawTextHelpFormatter, + ) + + functions_subparsers = add_grouped_subparsers( + functions_parser, dest="functions_subcommand", metavar="" + ) + + execute_parser = functions_subparsers.add_parser( + "execute", + parents=[parents.path, parents.scope, parents.json, parents.verbose], + help="Execute a function with the given arguments.", + description=( + "Execute a Function and print its return value, logs and runtime.\n\n" + "Examples:\n" + " poly functions execute \n" + " poly functions execute --args '{\"x\": 1}'\n" + ), + formatter_class=RawTextHelpFormatter, + ) + execute_parser.add_argument("function_id", type=str, help="The function ID.") + execute_parser.add_argument( + "--args", + type=str, + default="{}", + help="JSON object of arguments to pass to the function. Defaults to {}.", + ) + + functions_subparsers.add_parser( + "deploy", + parents=[parents.path, parents.scope, parents.json, parents.verbose], + help="Deploy all draft functions on the current branch.", + description=( + "Deploy every draft Function on the current branch.\n\n" + "Examples:\n" + " poly functions deploy\n" + ), + formatter_class=RawTextHelpFormatter, + ) + + functions_subparsers.add_parser( + "validate", + parents=[parents.path, parents.scope, parents.json, parents.verbose], + help="Validate all functions on the current branch.", + description=( + "Check every Function on the current branch for syntax errors and\n" + "orphaned flow-step references.\n\n" + "Examples:\n" + " poly functions validate\n" + ), + formatter_class=RawTextHelpFormatter, + ) + + references_parser = functions_subparsers.add_parser( + "references", + parents=[parents.path, parents.scope, parents.json, parents.verbose], + help="Show flow steps that reference a function.", + description=( + "List the flow steps that call a Function.\n\n" + "Examples:\n" + " poly functions references \n" + ), + formatter_class=RawTextHelpFormatter, + ) + references_parser.add_argument("function_id", type=str, help="The function ID.") + + type_definitions_parser = functions_subparsers.add_parser( + "type-definitions", + parents=[parents.path, parents.scope, parents.json, parents.verbose], + help="Show Python type stubs for a function, for IDE autocomplete.", + description=( + "Print the Conversation/Flow type stubs available to a Function, for\n" + "IDE autocomplete.\n\n" + "Examples:\n" + " poly functions type-definitions \n" + " poly functions type-definitions > stubs.py\n" + ), + formatter_class=RawTextHelpFormatter, + ) + type_definitions_parser.add_argument("function_id", type=str, help="The function ID.") + + functions_subparsers.add_parser( + "deployments", + parents=[parents.path, parents.scope, parents.json, parents.verbose], + help="List function deployment history across environments.", + description=( + "Show the deployment history for the project's Functions.\n\n" + "Examples:\n" + " poly functions deployments\n" + ), + formatter_class=RawTextHelpFormatter, + ) + + group_subcommands( + functions_subparsers, FUNCTIONS_SUBCOMMAND_GROUPS, FUNCTIONS_SUBCOMMAND_GROUP_ORDER + ) + + @classmethod + def run(cls, args: Namespace) -> None: + """Dispatch to the matching functions sub-handler.""" + scope = dict(region=args.region, project_id=args.project_id, branch_id=args.branch_id) + if args.functions_subcommand == "execute": + cls.functions_execute( + args.path, + args.function_id, + args.args, + output_json=args.json, + **scope, + ) + elif args.functions_subcommand == "deploy": + cls.functions_deploy(args.path, output_json=args.json, **scope) + elif args.functions_subcommand == "validate": + cls.functions_validate(args.path, output_json=args.json, **scope) + elif args.functions_subcommand == "references": + cls.functions_references(args.path, args.function_id, output_json=args.json, **scope) + elif args.functions_subcommand == "type-definitions": + cls.functions_type_definitions( + args.path, args.function_id, output_json=args.json, **scope + ) + elif args.functions_subcommand == "deployments": + cls.functions_deployments(args.path, output_json=args.json, **scope) + + @staticmethod + def _parse_json_arg(value: str, flag: str, output_json: bool) -> Any: + """Parse a JSON-valued CLI flag, exiting with a clear error if invalid. + + Args: + value: The raw flag value. + flag: The flag name, for the error message. + output_json: If True, emit machine-readable JSON. + + Returns: + The parsed JSON value. + """ + from poly.output.console import error + + try: + return json.loads(value) + except json.JSONDecodeError as e: + msg = f"Invalid JSON in {flag}: {e}" + if output_json: + json_print({"success": False, "error": msg}) + else: + error(msg) + sys.exit(1) + + @classmethod + def functions_execute( + cls, + base_path: str, + function_id: str, + args_json: str = "{}", + output_json: bool = False, + region: Optional[str] = None, + project_id: Optional[str] = None, + branch_id: Optional[str] = None, + ) -> None: + """Execute a function with the given JSON arguments. + + Args: + base_path: Base path for the project. + function_id: The function ID. + args_json: JSON object of arguments to pass. + output_json: If True, emit machine-readable JSON. + region: Explicit region, bypassing the local project. + project_id: Explicit project ID, bypassing the local project. + branch_id: Explicit branch ID, bypassing the local project. + """ + from poly.output.console import console + + region, project_id, branch_id = resolve_project_scope( + base_path, region, project_id, branch_id, output_json=output_json + ) + args = cls._parse_json_arg(args_json, "--args", output_json) + + result = AgentStudioInterface.execute_function( + region=region, + project_id=project_id, + branch_id=branch_id, + function_id=function_id, + args=args, + ) + + if output_json: + json_print(result) + else: + console.print(f"Runtime: {result.get('runtime', 0)}ms") + for log_line in result.get("logs", []): + console.print(f"[muted]{log_line}[/muted]") + console.print(json.dumps(result.get("body", {}), indent=2)) + + @classmethod + def functions_deploy( + cls, + base_path: str, + output_json: bool = False, + region: Optional[str] = None, + project_id: Optional[str] = None, + branch_id: Optional[str] = None, + ) -> None: + """Deploy all draft functions on the current branch. + + Args: + base_path: Base path for the project. + output_json: If True, emit machine-readable JSON. + region: Explicit region, bypassing the local project. + project_id: Explicit project ID, bypassing the local project. + branch_id: Explicit branch ID, bypassing the local project. + """ + from poly.output.console import success + + region, project_id, branch_id = resolve_project_scope( + base_path, region, project_id, branch_id, output_json=output_json + ) + result = AgentStudioInterface.deploy_functions( + region=region, + project_id=project_id, + branch_id=branch_id, + ) + + if output_json: + json_print(result) + else: + success(f"Deployed functions (version {result.get('deployment_version', 'unknown')}).") + + @classmethod + def functions_validate( + cls, + base_path: str, + output_json: bool = False, + region: Optional[str] = None, + project_id: Optional[str] = None, + branch_id: Optional[str] = None, + ) -> None: + """Validate all functions on the current branch. + + Args: + base_path: Base path for the project. + output_json: If True, emit machine-readable JSON. + region: Explicit region, bypassing the local project. + project_id: Explicit project ID, bypassing the local project. + branch_id: Explicit branch ID, bypassing the local project. + """ + from poly.output.console import print_function_validation_issues + + region, project_id, branch_id = resolve_project_scope( + base_path, region, project_id, branch_id, output_json=output_json + ) + result = AgentStudioInterface.validate_functions( + region=region, + project_id=project_id, + branch_id=branch_id, + ) + + if output_json: + json_print(result) + else: + print_function_validation_issues(result.get("valid", False), result.get("issues", [])) + + @classmethod + def functions_references( + cls, + base_path: str, + function_id: str, + output_json: bool = False, + region: Optional[str] = None, + project_id: Optional[str] = None, + branch_id: Optional[str] = None, + ) -> None: + """Show the flow steps that reference a function. + + Args: + base_path: Base path for the project. + function_id: The function ID. + output_json: If True, emit machine-readable JSON. + region: Explicit region, bypassing the local project. + project_id: Explicit project ID, bypassing the local project. + branch_id: Explicit branch ID, bypassing the local project. + """ + from poly.output.console import print_function_references + + region, project_id, branch_id = resolve_project_scope( + base_path, region, project_id, branch_id, output_json=output_json + ) + result = AgentStudioInterface.get_function_references( + region=region, + project_id=project_id, + branch_id=branch_id, + function_id=function_id, + ) + + if output_json: + json_print(result) + else: + print_function_references(result.get("references", [])) + + @classmethod + def functions_type_definitions( + cls, + base_path: str, + function_id: str, + output_json: bool = False, + region: Optional[str] = None, + project_id: Optional[str] = None, + branch_id: Optional[str] = None, + ) -> None: + """Show Python type stubs for a function, for IDE autocomplete. + + Args: + base_path: Base path for the project. + function_id: The function ID. + output_json: If True, emit machine-readable JSON. + region: Explicit region, bypassing the local project. + project_id: Explicit project ID, bypassing the local project. + branch_id: Explicit branch ID, bypassing the local project. + """ + from poly.output.console import print_code + + region, project_id, branch_id = resolve_project_scope( + base_path, region, project_id, branch_id, output_json=output_json + ) + result = AgentStudioInterface.get_function_type_definitions( + region=region, + project_id=project_id, + branch_id=branch_id, + function_id=function_id, + ) + + if output_json: + json_print(result) + else: + print_code(result.get("code", ""), line_numbers=False) + + @classmethod + def functions_deployments( + cls, + base_path: str, + output_json: bool = False, + region: Optional[str] = None, + project_id: Optional[str] = None, + branch_id: Optional[str] = None, + ) -> None: + """List function deployment history across environments. + + Args: + base_path: Base path for the project. + output_json: If True, emit machine-readable JSON. + region: Explicit region, bypassing the local project. + project_id: Explicit project ID, bypassing the local project. + branch_id: Explicit branch ID, bypassing the local project. + """ + from poly.output.console import info, print_function_deployments + + region, project_id, branch_id = resolve_project_scope( + base_path, region, project_id, branch_id, output_json=output_json + ) + result = AgentStudioInterface.list_function_deployments( + region=region, + project_id=project_id, + branch_id=branch_id, + ) + deployments = result.get("deployments", []) + + if output_json: + json_print(result) + else: + if not deployments: + info("No function deployments found.") + return + print_function_deployments(deployments) diff --git a/src/poly/cli_commands/project.py b/src/poly/cli_commands/project.py index 33626ee8..1065e358 100644 --- a/src/poly/cli_commands/project.py +++ b/src/poly/cli_commands/project.py @@ -9,7 +9,7 @@ from argparse import SUPPRESS, ArgumentParser, Namespace, RawTextHelpFormatter, _SubParsersAction from contextlib import nullcontext -from poly.cli_commands.base import BaseCommand, Parents +from poly.cli_commands.base import GETTING_STARTED_GROUP, BaseCommand, Parents from poly.cli_commands.shared import load_project, parse_from_projection_json from poly.handlers.interface import REGIONS, AgentStudioInterface from poly.output.json_output import json_print @@ -21,6 +21,8 @@ class ProjectCommand(BaseCommand): command = "project" + group = GETTING_STARTED_GROUP + @classmethod def add_arguments(cls, subparsers: _SubParsersAction[ArgumentParser], parents: Parents) -> None: """Register the ``project`` subcommand tree.""" @@ -835,6 +837,8 @@ class InitCommand(BaseCommand): command = "init" + group = GETTING_STARTED_GROUP + @classmethod def add_arguments(cls, subparsers: _SubParsersAction[ArgumentParser], parents: Parents) -> None: """Register the ``init`` subcommand.""" @@ -1105,6 +1109,8 @@ class StudioCommand(BaseCommand): command = "studio" + group = GETTING_STARTED_GROUP + @classmethod def add_arguments(cls, subparsers: _SubParsersAction[ArgumentParser], parents: Parents) -> None: """Register the ``studio`` subcommand.""" diff --git a/src/poly/cli_commands/review.py b/src/poly/cli_commands/review.py index 2238350e..9cf4acee 100644 --- a/src/poly/cli_commands/review.py +++ b/src/poly/cli_commands/review.py @@ -7,7 +7,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 PROJECT_SYNC_GROUP, BaseCommand, Parents from poly.cli_commands.shared import compute_diff, format_gist_choice from poly.handlers.github_api_handler import GitHubAPIHandler from poly.output.json_output import json_print @@ -18,6 +18,8 @@ class ReviewCommand(BaseCommand): command = "review" + group = PROJECT_SYNC_GROUP + @classmethod def add_arguments(cls, subparsers: _SubParsersAction[ArgumentParser], parents: Parents) -> None: """Register the ``review`` subcommand tree.""" diff --git a/src/poly/cli_commands/rtc.py b/src/poly/cli_commands/rtc.py index eb2af2c4..c8bb30ff 100644 --- a/src/poly/cli_commands/rtc.py +++ b/src/poly/cli_commands/rtc.py @@ -8,7 +8,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 PROJECT_SYNC_GROUP, BaseCommand, Parents from poly.cli_commands.shared import load_project from poly.output.console import edit_in_editor, error, info, success, warning from poly.output.json_output import json_print @@ -124,6 +124,8 @@ class RTCCommand(BaseCommand): command = "rtc" + group = PROJECT_SYNC_GROUP + @classmethod def add_arguments(cls, subparsers: _SubParsersAction[ArgumentParser], parents: Parents) -> None: """Register the ``rtc`` subcommand tree.""" diff --git a/src/poly/cli_commands/shared.py b/src/poly/cli_commands/shared.py index 6a33706f..b20da6ff 100644 --- a/src/poly/cli_commands/shared.py +++ b/src/poly/cli_commands/shared.py @@ -76,6 +76,50 @@ def load_project(base_path: str, output_json: bool = False) -> AgentStudioProjec return project +def resolve_project_scope( + base_path: str, + region: Optional[str], + project_id: Optional[str], + branch_id: Optional[str], + output_json: bool = False, +) -> tuple[str, str, str]: + """Resolve region/project_id/branch_id from explicit flags or the local project. + + Lets headless callers (CI, scripts) skip the local project checkout + entirely by passing all three explicitly, instead of requiring + ``load_project`` to read one from disk. + + Args: + base_path: Base path for the local project, used as a fallback. + region: Explicit region, if given. + project_id: Explicit project ID, if given. + branch_id: Explicit branch ID, if given. + output_json: If True, print JSON and exit on error instead of a message. + + Returns: + The resolved (region, project_id, branch_id). + """ + from poly.output.console import error + + explicit = (region, project_id, branch_id) + if all(value is not None for value in explicit): + return region, project_id, branch_id + + if any(value is not None for value in explicit): + message = ( + "--region, --project_id and --branch_id must all be given together, " + "or all omitted to use the local project." + ) + if output_json: + json_print({"success": False, "error": message}) + sys.exit(1) + error(message) + sys.exit(1) + + project = load_project(base_path, output_json=output_json) + return project.region, project.project_id, project.branch_id + + def compute_diff( base_path: str, files: list[str] = None, diff --git a/src/poly/cli_commands/sync.py b/src/poly/cli_commands/sync.py index a5636661..0d4cc2a0 100644 --- a/src/poly/cli_commands/sync.py +++ b/src/poly/cli_commands/sync.py @@ -12,7 +12,7 @@ from contextlib import nullcontext from typing import Any, Optional -from poly.cli_commands.base import BaseCommand, Parents +from poly.cli_commands.base import PROJECT_SYNC_GROUP, BaseCommand, Parents from poly.cli_commands.shared import ( compute_diff, load_project, @@ -124,6 +124,8 @@ class PullCommand(BaseCommand): command = "pull" + group = PROJECT_SYNC_GROUP + @classmethod def add_arguments(cls, subparsers: _SubParsersAction[ArgumentParser], parents: Parents) -> None: """Register the ``pull`` subcommand.""" @@ -277,6 +279,8 @@ class PushCommand(BaseCommand): command = "push" + group = PROJECT_SYNC_GROUP + @classmethod def add_arguments(cls, subparsers: _SubParsersAction[ArgumentParser], parents: Parents) -> None: """Register the ``push`` subcommand.""" @@ -461,6 +465,8 @@ class StatusCommand(BaseCommand): command = "status" + group = PROJECT_SYNC_GROUP + @classmethod def add_arguments(cls, subparsers: _SubParsersAction[ArgumentParser], parents: Parents) -> None: """Register the ``status`` subcommand.""" @@ -497,6 +503,8 @@ class RevertCommand(BaseCommand): command = "revert" + group = PROJECT_SYNC_GROUP + @classmethod def add_arguments(cls, subparsers: _SubParsersAction[ArgumentParser], parents: Parents) -> None: """Register the ``revert`` subcommand.""" @@ -562,6 +570,8 @@ class DiffCommand(BaseCommand): command = "diff" + group = PROJECT_SYNC_GROUP + @classmethod def add_arguments(cls, subparsers: _SubParsersAction[ArgumentParser], parents: Parents) -> None: """Register the ``diff`` subcommand.""" @@ -663,6 +673,8 @@ class FormatCommand(BaseCommand): command = "format" + group = PROJECT_SYNC_GROUP + @classmethod def add_arguments(cls, subparsers: _SubParsersAction[ArgumentParser], parents: Parents) -> None: """Register the ``format`` subparser.""" @@ -857,6 +869,8 @@ class ValidateCommand(BaseCommand): command = "validate" + group = PROJECT_SYNC_GROUP + @classmethod def add_arguments(cls, subparsers: _SubParsersAction[ArgumentParser], parents: Parents) -> None: """Register the ``validate`` subparser.""" diff --git a/src/poly/cli_commands/testing.py b/src/poly/cli_commands/testing.py index 960f180c..eac0833f 100644 --- a/src/poly/cli_commands/testing.py +++ b/src/poly/cli_commands/testing.py @@ -6,7 +6,7 @@ import sys from argparse import ArgumentParser, Namespace, RawTextHelpFormatter, _SubParsersAction -from poly.cli_commands.base import BaseCommand, Parents +from poly.cli_commands.base import PROJECT_SYNC_GROUP, BaseCommand, Parents from poly.cli_commands.shared import load_project from poly.output.json_output import json_print @@ -16,6 +16,8 @@ class TestingCommand(BaseCommand): command = "test" + group = PROJECT_SYNC_GROUP + @classmethod def add_arguments(cls, subparsers: _SubParsersAction[ArgumentParser], parents: Parents) -> None: """Register the ``test`` subcommand tree.""" diff --git a/src/poly/cli_commands/utils.py b/src/poly/cli_commands/utils.py index a6e01870..6ec13595 100644 --- a/src/poly/cli_commands/utils.py +++ b/src/poly/cli_commands/utils.py @@ -9,7 +9,7 @@ import argcomplete -from poly.cli_commands.base import BaseCommand, Parents +from poly.cli_commands.base import OTHER_GROUP, BaseCommand, Parents from poly.project import AgentStudioProject DOCUMENT_CHOICES = AgentStudioProject.discover_docs() @@ -20,6 +20,8 @@ class DocsCommand(BaseCommand): command = "docs" + group = OTHER_GROUP + @classmethod def add_arguments(cls, subparsers: _SubParsersAction[ArgumentParser], parents: Parents) -> None: """Register the ``docs`` subcommand.""" @@ -98,6 +100,8 @@ class CompletionCommand(BaseCommand): command = "completion" + group = OTHER_GROUP + @classmethod def add_arguments(cls, subparsers: _SubParsersAction[ArgumentParser], parents: Parents) -> None: """Register the ``completion`` subcommand.""" diff --git a/src/poly/docs/functions.md b/src/poly/docs/functions.md index 3acb17e9..e4bca611 100644 --- a/src/poly/docs/functions.md +++ b/src/poly/docs/functions.md @@ -4,6 +4,8 @@ Functions are Python files that add deterministic logic to your agent. They can be called by the LLM during conversation, used as flow steps, or run automatically at call start/end. +> To manage functions programmatically over the REST API instead of via local files, see the `poly functions` commands (`poly functions --help`). + ## Location ``` functions/ # Global functions diff --git a/src/poly/handlers/interface.py b/src/poly/handlers/interface.py index 5bb2b757..9da6aa0a 100644 --- a/src/poly/handlers/interface.py +++ b/src/poly/handlers/interface.py @@ -1402,6 +1402,112 @@ def patch_rtc_variables( """ return PlatformAPIHandler.patch_rtc_variables(region, project_id, client_env, variables) + # -- Functions API ------------------------------------------------------ + # Public REST API for managing/executing user-defined Functions. Distinct + # from the local-file/decorator Functions synced via push/pull. + + @staticmethod + def execute_function( + region: str, + project_id: str, + branch_id: str, + function_id: str, + args: dict, + ) -> dict: + """Execute a function with the given arguments. + + Args: + region: The region name. + project_id: The project ID (agent ID). + branch_id: The branch ID. + function_id: The function ID. + args: The arguments to pass to the function. + + Returns: + dict: {"body": ..., "logs": [...], "runtime": ...}. + """ + return PlatformAPIHandler.execute_function(region, project_id, branch_id, function_id, args) + + @staticmethod + def deploy_functions(region: str, project_id: str, branch_id: str) -> dict: + """Deploy all draft functions on a branch. + + Args: + region: The region name. + project_id: The project ID (agent ID). + branch_id: The branch ID. + + Returns: + dict: {"deployment_version": ..., "function_ids": [...], "deployed_at": ...}. + """ + return PlatformAPIHandler.deploy_functions(region, project_id, branch_id) + + @staticmethod + def validate_functions(region: str, project_id: str, branch_id: str) -> dict: + """Validate all functions on a branch. + + Args: + region: The region name. + project_id: The project ID (agent ID). + branch_id: The branch ID. + + Returns: + dict: {"valid": bool, "issues": [...]}. + """ + return PlatformAPIHandler.validate_functions(region, project_id, branch_id) + + @staticmethod + def get_function_references( + region: str, project_id: str, branch_id: str, function_id: str + ) -> dict: + """Get the flow steps that reference a function. + + Args: + region: The region name. + project_id: The project ID (agent ID). + branch_id: The branch ID. + function_id: The function ID. + + Returns: + dict: {"references": [...]}. + """ + return PlatformAPIHandler.get_function_references( + region, project_id, branch_id, function_id + ) + + @staticmethod + def get_function_type_definitions( + region: str, project_id: str, branch_id: str, function_id: str + ) -> dict: + """Get Python type stubs for a function, for IDE autocomplete. + + Args: + region: The region name. + project_id: The project ID (agent ID). + branch_id: The branch ID. + function_id: The function ID. + + Returns: + dict: {"code": ...}. + """ + return PlatformAPIHandler.get_function_type_definitions( + region, project_id, branch_id, function_id + ) + + @staticmethod + def list_function_deployments(region: str, project_id: str, branch_id: str) -> dict: + """List function deployment history across environments. + + Args: + region: The region name. + project_id: The project ID (agent ID). + branch_id: The branch ID. + + Returns: + dict: {"deployments": [...]}. + """ + return PlatformAPIHandler.list_function_deployments(region, project_id, branch_id) + def get_branch_history(self, branch_id: str) -> list[dict[str, Any]]: """Get the history of a specific branch. diff --git a/src/poly/handlers/platform_api.py b/src/poly/handlers/platform_api.py index ad6584d7..e5eb868e 100644 --- a/src/poly/handlers/platform_api.py +++ b/src/poly/handlers/platform_api.py @@ -54,6 +54,18 @@ RTC_CONFIG_URL = "/v1/agents/{project_id}/real-time-configs/{client_env}" RTC_SCHEMA_URL = "/v1/agents/{project_id}/real-time-configs/{client_env}/schema" RTC_VARIABLES_URL = "/v1/agents/{project_id}/real-time-configs/{client_env}/variables" +FUNCTION_EXECUTE_URL = ( + "/v1/agents/{project_id}/branches/{branch_id}/functions/{function_id}/execute" +) +FUNCTION_REFERENCES_URL = ( + "/v1/agents/{project_id}/branches/{branch_id}/functions/{function_id}/references" +) +FUNCTION_TYPE_DEFINITIONS_URL = ( + "/v1/agents/{project_id}/branches/{branch_id}/functions/{function_id}/type_definitions" +) +FUNCTIONS_VALIDATE_URL = "/v1/agents/{project_id}/branches/{branch_id}/functions/validate" +FUNCTIONS_DEPLOY_URL = "/v1/agents/{project_id}/branches/{branch_id}/functions/deploy" +FUNCTIONS_DEPLOYMENTS_URL = "/v1/agents/{project_id}/branches/{branch_id}/functions/deployments" class PlatformAPIHandler: @@ -1420,3 +1432,113 @@ def patch_rtc_variables( endpoint = RTC_VARIABLES_URL.format(project_id=project_id, client_env=client_env) data = {"variables": variables} return PlatformAPIHandler.make_request(region, endpoint, "PATCH", data=data) + + @staticmethod + def execute_function( + region: str, + project_id: str, + branch_id: str, + function_id: str, + args: dict, + ) -> dict: + """Execute a function with the given arguments. + + Args: + region: The region name. + project_id: The project ID (agent ID). + branch_id: The branch ID. + function_id: The function ID. + args: The arguments to pass to the function. + + Returns: + dict: {"body": ..., "logs": [...], "runtime": ...}. + """ + endpoint = FUNCTION_EXECUTE_URL.format( + project_id=project_id, branch_id=branch_id, function_id=function_id + ) + return PlatformAPIHandler.make_request(region, endpoint, "POST", data={"args": args}) + + @staticmethod + def deploy_functions(region: str, project_id: str, branch_id: str) -> dict: + """Deploy all draft functions on a branch. + + Args: + region: The region name. + project_id: The project ID (agent ID). + branch_id: The branch ID. + + Returns: + dict: {"deployment_version": ..., "function_ids": [...], "deployed_at": ...}. + """ + endpoint = FUNCTIONS_DEPLOY_URL.format(project_id=project_id, branch_id=branch_id) + return PlatformAPIHandler.make_request(region, endpoint, "POST") + + @staticmethod + def validate_functions(region: str, project_id: str, branch_id: str) -> dict: + """Validate all functions on a branch for orphaned refs and syntax errors. + + Args: + region: The region name. + project_id: The project ID (agent ID). + branch_id: The branch ID. + + Returns: + dict: {"valid": bool, "issues": [...]}. + """ + endpoint = FUNCTIONS_VALIDATE_URL.format(project_id=project_id, branch_id=branch_id) + return PlatformAPIHandler.make_request(region, endpoint, "POST") + + @staticmethod + def get_function_references( + region: str, project_id: str, branch_id: str, function_id: str + ) -> dict: + """Get the flow steps that reference a function. + + Args: + region: The region name. + project_id: The project ID (agent ID). + branch_id: The branch ID. + function_id: The function ID. + + Returns: + dict: {"references": [...]}. + """ + endpoint = FUNCTION_REFERENCES_URL.format( + project_id=project_id, branch_id=branch_id, function_id=function_id + ) + return PlatformAPIHandler.make_request(region, endpoint, "GET") + + @staticmethod + def get_function_type_definitions( + region: str, project_id: str, branch_id: str, function_id: str + ) -> dict: + """Get Python type stubs for a function, for IDE autocomplete. + + Args: + region: The region name. + project_id: The project ID (agent ID). + branch_id: The branch ID. + function_id: The function ID. + + Returns: + dict: {"code": ...}. + """ + endpoint = FUNCTION_TYPE_DEFINITIONS_URL.format( + project_id=project_id, branch_id=branch_id, function_id=function_id + ) + return PlatformAPIHandler.make_request(region, endpoint, "GET") + + @staticmethod + def list_function_deployments(region: str, project_id: str, branch_id: str) -> dict: + """List function deployment history across environments. + + Args: + region: The region name. + project_id: The project ID (agent ID). + branch_id: The branch ID. + + Returns: + dict: {"deployments": [...]}. + """ + endpoint = FUNCTIONS_DEPLOYMENTS_URL.format(project_id=project_id, branch_id=branch_id) + return PlatformAPIHandler.make_request(region, endpoint, "GET") diff --git a/src/poly/output/console.py b/src/poly/output/console.py index 892219d4..54902897 100644 --- a/src/poly/output/console.py +++ b/src/poly/output/console.py @@ -128,6 +128,11 @@ def print_diff(diff: str) -> None: console.print(Syntax(diff, "diff", theme="ansi_dark", line_numbers=False)) +def print_code(code: str, line_numbers: bool = True) -> None: + """Print Python code with syntax highlighting.""" + console.print(Syntax(code, "python", theme="ansi_dark", line_numbers=line_numbers)) + + def print_agents(agents: list[dict[str, Any]]) -> None: """Print a table of agents. @@ -1048,6 +1053,73 @@ def print_audio_cache_entries(entries: list[dict[str, Any]]) -> None: console.print(table) +def print_function_references(references: list[dict[str, Any]]) -> None: + """Print the flow steps that reference a Function. + + Args: + references: List of flow step reference dicts. + """ + if not references: + console.print("[muted]No references.[/muted]") + return + + table = Table(box=None, show_header=True, header_style="bold", padding=(0, 1)) + table.add_column("Flow", no_wrap=True) + table.add_column("Step", no_wrap=True) + + for ref in references: + table.add_row(ref.get("flow_name", "—"), ref.get("step_name", "—")) + + console.print(table) + + +def print_function_deployments(deployments: list[dict[str, Any]]) -> None: + """Print the deployment history for a project's functions. + + Args: + deployments: List of function deployment record dicts. + """ + table = Table(box=None, show_header=True, header_style="bold", padding=(0, 1)) + table.add_column("Environment", style="cyan", no_wrap=True) + table.add_column("Deployment Version", style="bold yellow", no_wrap=True) + table.add_column("Deployed At", no_wrap=True) + table.add_column("Functions", overflow="fold") + + for d in deployments: + deployed_at = d.get("deployed_at") or "—" + if deployed_at != "—": + deployed_at = _format_iso_timestamp(deployed_at) + table.add_row( + d.get("environment", "—"), + str(d.get("deployment_version", "—")), + deployed_at, + ", ".join(d.get("function_ids", [])) or "—", + ) + + console.print(table) + + +def print_function_validation_issues(valid: bool, issues: list[dict[str, Any]]) -> None: + """Print the result of validating a branch's functions. + + Args: + valid: Whether the branch's functions passed validation. + issues: The reported issue dicts, if any. + """ + if valid: + success("Functions are valid.") + return + + error("Functions failed validation.") + for issue in issues: + name = issue.get("function_name") + location = f" ({name})" if name else "" + detail = { + k: v for k, v in issue.items() if k not in ("type", "function_id", "function_name") + } + console.print(f" [error]-[/error] {issue.get('type', 'unknown')}{location}: {detail}") + + def print_conversation_detail(conversation: dict[str, Any], studio_url: str | None = None) -> None: """Print detailed conversation information including turns. diff --git a/src/poly/tests/api/platform_api_test.py b/src/poly/tests/api/platform_api_test.py index a4a334c1..f3a9c2cd 100644 --- a/src/poly/tests/api/platform_api_test.py +++ b/src/poly/tests/api/platform_api_test.py @@ -10,7 +10,10 @@ import requests -from poly.handlers.platform_api import ACCOUNTS_URL, PlatformAPIHandler +from poly.handlers.platform_api import ( + ACCOUNTS_URL, + PlatformAPIHandler, +) from poly.tests.testing_utils import make_mock_response @@ -456,6 +459,92 @@ def test_error_status_raises_http_error(self, mock_request, _mock_key): PlatformAPIHandler.synthesize_audio_cache("studio", "agent-1", "entry-1", "hi", {}) +class ExecuteFunction(unittest.TestCase): + """Tests for PlatformAPIHandler.execute_function.""" + + @patch("poly.handlers.platform_api.retrieve_api_key", return_value="secret-key") + @patch("poly.handlers.platform_api.requests.request") + def test_wraps_args_in_body_and_returns_result(self, mock_request, _mock_key): + """Arguments are wrapped in an "args" object and the result is returned.""" + payload = {"body": {"ok": True}, "logs": ["line"], "runtime": 12} + mock_request.return_value = make_mock_response(200, json_body=payload) + + result = PlatformAPIHandler.execute_function( + "studio", "agent-1", "branch-1", "fn-1", {"x": 1} + ) + + self.assertEqual(result, payload) + self.assertEqual(json.loads(mock_request.call_args.kwargs["data"]), {"args": {"x": 1}}) + self.assertIn("/functions/fn-1/execute", mock_request.call_args.kwargs["url"]) + + +class DeployAndValidateFunctions(unittest.TestCase): + """Tests for the functions deploy/validate/deployments endpoints.""" + + @patch("poly.handlers.platform_api.retrieve_api_key", return_value="secret-key") + @patch("poly.handlers.platform_api.requests.request") + def test_deploy_posts_to_deploy_endpoint(self, mock_request, _mock_key): + """Deploy is a POST returning the new deployment record.""" + payload = {"deployment_version": "v3", "function_ids": ["fn-1"], "deployed_at": "now"} + mock_request.return_value = make_mock_response(200, json_body=payload) + + result = PlatformAPIHandler.deploy_functions("studio", "agent-1", "branch-1") + + self.assertEqual(result, payload) + self.assertEqual(mock_request.call_args.kwargs["method"], "POST") + self.assertIn("/functions/deploy", mock_request.call_args.kwargs["url"]) + + @patch("poly.handlers.platform_api.retrieve_api_key", return_value="secret-key") + @patch("poly.handlers.platform_api.requests.request") + def test_validate_returns_valid_and_issues(self, mock_request, _mock_key): + """Validate returns the raw {"valid": ..., "issues": [...]} payload.""" + payload = {"valid": False, "issues": [{"type": "syntax_error", "function_id": "fn-1"}]} + mock_request.return_value = make_mock_response(200, json_body=payload) + + result = PlatformAPIHandler.validate_functions("studio", "agent-1", "branch-1") + + self.assertEqual(result, payload) + self.assertIn("/functions/validate", mock_request.call_args.kwargs["url"]) + + @patch("poly.handlers.platform_api.retrieve_api_key", return_value="secret-key") + @patch("poly.handlers.platform_api.requests.request") + def test_list_deployments_uses_deployments_endpoint(self, mock_request, _mock_key): + """Deployment history is a GET against the deployments endpoint.""" + mock_request.return_value = make_mock_response(200, json_body={"deployments": []}) + + PlatformAPIHandler.list_function_deployments("studio", "agent-1", "branch-1") + + self.assertEqual(mock_request.call_args.kwargs["method"], "GET") + self.assertIn("/functions/deployments", mock_request.call_args.kwargs["url"]) + + +class FunctionReferencesAndTypeDefinitions(unittest.TestCase): + """Tests for the function references and type-definitions endpoints.""" + + @patch("poly.handlers.platform_api.retrieve_api_key", return_value="secret-key") + @patch("poly.handlers.platform_api.requests.request") + def test_references_endpoint(self, mock_request, _mock_key): + """References are fetched from the per-function references endpoint.""" + mock_request.return_value = make_mock_response(200, json_body={"references": []}) + + PlatformAPIHandler.get_function_references("studio", "agent-1", "branch-1", "fn-1") + + self.assertIn("/functions/fn-1/references", mock_request.call_args.kwargs["url"]) + + @patch("poly.handlers.platform_api.retrieve_api_key", return_value="secret-key") + @patch("poly.handlers.platform_api.requests.request") + def test_type_definitions_endpoint(self, mock_request, _mock_key): + """Type stubs are fetched from the per-function type_definitions endpoint.""" + mock_request.return_value = make_mock_response(200, json_body={"code": "class Conv: ..."}) + + result = PlatformAPIHandler.get_function_type_definitions( + "studio", "agent-1", "branch-1", "fn-1" + ) + + self.assertEqual(result, {"code": "class Conv: ..."}) + self.assertIn("/functions/fn-1/type_definitions", mock_request.call_args.kwargs["url"]) + + def _make_request_headers() -> dict: """Call make_request with the network mocked and return the headers it sent.""" with ( diff --git a/src/poly/tests/cli_test.py b/src/poly/tests/cli_test.py index 1e83d4e9..d46edfea 100644 --- a/src/poly/tests/cli_test.py +++ b/src/poly/tests/cli_test.py @@ -5,17 +5,40 @@ import os import unittest +from argparse import ArgumentParser, _SubParsersAction from io import StringIO from unittest.mock import MagicMock, patch from poly.cli import AgentStudioCLI from poly.cli_commands.audio_cache import AudioCacheCommand -from poly.cli_commands.branch import BranchCommand +from poly.cli_commands.base import ( + BUILDER_API_GROUP, + COMMAND_GROUP_ORDER, + OTHER_GROUP, + GroupedHelpFormatter, + add_grouped_subparsers, + group_subcommands, +) +from poly.cli_commands.branch import ( + BRANCH_INSPECT_GROUP, + BRANCH_LIFECYCLE_GROUP, + BRANCH_SUBCOMMAND_GROUPS, + 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 ( + FUNCTIONS_SUBCOMMAND_GROUP_ORDER, + FUNCTIONS_SUBCOMMAND_GROUPS, + FunctionsCommand, +) from poly.cli_commands.project import InitCommand, ProjectCommand -from poly.cli_commands.shared import compute_diff, require_deployment_simplification +from poly.cli_commands.shared import ( + compute_diff, + require_deployment_simplification, + resolve_project_scope, +) from poly.cli_commands.sync import FormatCommand, RevertCommand from poly.cli_commands.utils import CompletionCommand from poly.project import DeploymentMode @@ -1802,6 +1825,53 @@ def test_completion_invalid_shell_rejected_by_parser(self): AgentStudioCLI().main(sys_args=["completion", "powershell"]) +class ResolveProjectScopeTest(unittest.TestCase): + """Tests for resolve_project_scope, used by headless-friendly commands.""" + + def setUp(self): + self.mock_load_patcher = patch("poly.cli_commands.shared.load_project") + self.mock_load = self.mock_load_patcher.start() + self.proj = MagicMock() + self.proj.region = "us-1" + self.proj.project_id = "local-project" + self.proj.branch_id = "local-branch" + self.mock_load.return_value = self.proj + + def tearDown(self): + patch.stopall() + + def test_all_explicit_bypasses_local_project(self): + """When all three are given, the local project is never read.""" + result = resolve_project_scope(TEST_DIR, "eu-1", "explicit-project", "explicit-branch") + + self.assertEqual(result, ("eu-1", "explicit-project", "explicit-branch")) + self.mock_load.assert_not_called() + + def test_none_given_falls_back_to_local_project(self): + """With nothing explicit, falls back to the local project config.""" + result = resolve_project_scope(TEST_DIR, None, None, None) + + self.assertEqual(result, ("us-1", "local-project", "local-branch")) + self.mock_load.assert_called_once_with(TEST_DIR, output_json=False) + + def test_partial_args_exits_with_clear_error(self): + """Giving only some of the three exits rather than silently mixing sources.""" + with patch("poly.output.console.error") as mock_error: + with self.assertRaises(SystemExit): + resolve_project_scope(TEST_DIR, "eu-1", None, None) + mock_error.assert_called_once() + self.mock_load.assert_not_called() + + def test_partial_args_json_mode_exits_with_json_error(self): + """The JSON-output path reports the same error as a JSON payload.""" + with patch("poly.cli_commands.shared.json_print") as mock_json: + with self.assertRaises(SystemExit): + resolve_project_scope(TEST_DIR, "eu-1", "explicit-project", None, output_json=True) + mock_json.assert_called_once() + self.assertFalse(mock_json.call_args[0][0]["success"]) + self.mock_load.assert_not_called() + + class ComputeDiffTest(unittest.TestCase): """Tests for the _compute_diff method.""" @@ -4322,6 +4392,308 @@ def test_synthesize_writes_preview_file(self, mock_success, mock_api, mock_open) self.assertIn("entry-1-preview.wav", mock_success.call_args[0][0]) +class FunctionsCommandTest(unittest.TestCase): + """Tests for the functions CLI commands (public Functions REST API).""" + + SAMPLE_FUNCTION = { + "function_id": "fn-1", + "name": "my_func", + "description": "desc", + "parameters": [{"name": "x", "type": "string", "description": "an x"}], + "code": "def my_func(conv, x: str):\n pass\n", + "active": True, + "usage_count": 2, + } + + def setUp(self): + self.mock_scope_patcher = patch("poly.cli_commands.functions.resolve_project_scope") + self.mock_scope = self.mock_scope_patcher.start() + self.mock_scope.return_value = ("us-1", "test-project", "branch-1") + + def tearDown(self): + patch.stopall() + + @patch("poly.cli_commands.functions.AgentStudioInterface.execute_function") + def test_execute_parses_args_json(self, mock_api): + """functions execute parses --args into an object before calling the API.""" + mock_api.return_value = {"body": {"ok": True}, "logs": [], "runtime": 5} + + FunctionsCommand.functions_execute(TEST_DIR, "fn-1", '{"x": 1}') + + mock_api.assert_called_once_with( + region="us-1", + project_id="test-project", + branch_id="branch-1", + function_id="fn-1", + args={"x": 1}, + ) + + @patch("poly.output.console.error") + def test_execute_invalid_args_json_exits(self, mock_error): + """functions execute rejects malformed --args JSON.""" + with self.assertRaises(SystemExit): + FunctionsCommand.functions_execute(TEST_DIR, "fn-1", "{not json") + + mock_error.assert_called_once() + + @patch("poly.cli_commands.functions.AgentStudioInterface.deploy_functions") + @patch("poly.output.console.success") + def test_deploy_reports_version(self, mock_success, mock_api): + """functions deploy reports the resulting deployment version.""" + mock_api.return_value = {"deployment_version": "v3", "function_ids": ["fn-1"]} + + FunctionsCommand.functions_deploy(TEST_DIR) + + self.assertIn("v3", mock_success.call_args[0][0]) + + @patch("poly.cli_commands.functions.AgentStudioInterface.validate_functions") + @patch("poly.output.console.print_function_validation_issues") + def test_validate_passes_valid_and_issues(self, mock_print, mock_api): + """functions validate hands the valid flag and issues to the printer.""" + issues = [{"type": "syntax_error", "function_id": "fn-1"}] + mock_api.return_value = {"valid": False, "issues": issues} + + FunctionsCommand.functions_validate(TEST_DIR) + + mock_print.assert_called_once_with(False, issues) + + @patch("poly.cli_commands.functions.AgentStudioInterface.get_function_references") + @patch("poly.output.console.print_function_references") + def test_references_prints_reference_list(self, mock_print, mock_api): + """functions references renders the reference list.""" + references = [{"flow_id": "flow-1", "flow_name": "Main", "step_name": "step-1"}] + mock_api.return_value = {"references": references} + + FunctionsCommand.functions_references(TEST_DIR, "fn-1") + + mock_print.assert_called_once_with(references) + + @patch("poly.cli_commands.functions.AgentStudioInterface.get_function_type_definitions") + @patch("poly.output.console.print_code") + def test_type_definitions_prints_code_without_line_numbers(self, mock_print, mock_api): + """Type stubs print unnumbered so they can be piped into a file.""" + mock_api.return_value = {"code": "class Conversation: ..."} + + FunctionsCommand.functions_type_definitions(TEST_DIR, "fn-1") + + mock_print.assert_called_once_with("class Conversation: ...", line_numbers=False) + + @patch("poly.cli_commands.functions.AgentStudioInterface.list_function_deployments") + @patch("poly.output.console.info") + def test_deployments_empty_shows_info(self, mock_info, mock_api): + """functions deployments with no history shows an info message.""" + mock_api.return_value = {"deployments": []} + + FunctionsCommand.functions_deployments(TEST_DIR) + + mock_info.assert_called_once() + + +# argparse wraps the usage line to the terminal width, which differs between the +# Linux and Windows CI runners. Pin it so grouped-help assertions are stable. +HELP_TEST_COLUMNS = "100" + + +def _format_help_at_fixed_width(parser) -> str: + """Render a parser's help text at a fixed terminal width.""" + with patch.dict(os.environ, {"COLUMNS": HELP_TEST_COLUMNS}): + return parser.format_help() + + +def _help_body(help_text: str) -> str: + """Return the help text after the usage block, which may wrap over lines.""" + _usage, _, body = help_text.partition("\n\n") + return body + + +class GroupedHelpOutputTest(unittest.TestCase): + """Tests for the grouped section headers in `poly --help`.""" + + def setUp(self): + cli = AgentStudioCLI() + cli.register_commands() + self.commands = cli.commands + self.help_text = _format_help_at_fixed_width(cli._create_parser()) + + def test_every_command_has_a_known_group(self): + """A command with a typo'd group would be silently dropped from its section.""" + for command in self.commands: + self.assertIn(command.group, COMMAND_GROUP_ORDER, msg=command.command) + + def test_every_command_is_listed_once(self): + """No command disappears from the help output when it is regrouped.""" + for command in self.commands: + with self.subTest(command=command.command): + self.assertIn(f" {command.command} ", self.help_text) + + def test_headers_appear_in_declared_order(self): + """Sections render in COMMAND_GROUP_ORDER, not registration order.""" + positions = [ + self.help_text.index(f"\n{title}:") + for title in COMMAND_GROUP_ORDER + if f"\n{title}:" in self.help_text + ] + self.assertEqual(positions, sorted(positions)) + + def test_builder_api_group_lists_api_backed_commands(self): + """The Builder API section holds the REST-API-backed command families.""" + section = self.help_text.split(f"\n{BUILDER_API_GROUP}:")[1] + section = section.split(f"\n{OTHER_GROUP}:")[0] + + for command in ["functions", "conversations", "audio-cache", "deployments"]: + self.assertIn(command, section) + self.assertNotIn("pull", section) + # rtc/test/chat operate on local project/branch state, not pure remote + # API calls, so they belong in Project sync instead. + for command in ["rtc", "test", "chat"]: + self.assertNotIn(f" {command} ", section) + + def test_commands_are_listed_before_options(self): + """Commands matter more than -h/-v, so they come first.""" + self.assertLess( + self.help_text.index(f"{COMMAND_GROUP_ORDER[0]}:"), + self.help_text.index("options:"), + ) + + def test_no_trailing_whitespace_or_blank_runs(self): + """The header mechanism must not leave ragged whitespace behind.""" + lines = self.help_text.split("\n") + self.assertEqual([line for line in lines if line != line.rstrip()], []) + self.assertNotIn("\n\n\n", self.help_text) + + def test_subparsers_placeholder_line_is_hidden(self): + """The metavar belongs in the usage line, not the body.""" + self.assertIn("", self.help_text.partition("\n\n")[0]) + self.assertNotIn("", _help_body(self.help_text)) + + def test_unknown_group_still_renders(self): + """A subcommand with an unrecognised group is shown rather than dropped.""" + parser = ArgumentParser(formatter_class=GroupedHelpFormatter) + subparsers = add_grouped_subparsers(parser, dest="command", metavar="") + subparsers.add_parser("known", help="A grouped command.") + subparsers.add_parser("stray", help="A stray command.") + + group_subcommands( + subparsers, + {"known": BUILDER_API_GROUP, "stray": "Not A Real Group"}, + [BUILDER_API_GROUP], + ) + + help_text = parser.format_help() + self.assertIn("stray", help_text) + self.assertIn(f"{OTHER_GROUP}:", help_text) + + def test_unmapped_subcommand_falls_back(self): + """A subcommand absent from the mapping still appears, under the fallback.""" + parser = ArgumentParser(formatter_class=GroupedHelpFormatter) + subparsers = add_grouped_subparsers(parser, dest="command", metavar="") + subparsers.add_parser("forgotten", help="Never assigned a group.") + + group_subcommands(subparsers, {}, [BUILDER_API_GROUP], fallback_group="Leftovers") + + help_text = parser.format_help() + self.assertIn("Leftovers:", help_text) + self.assertIn("forgotten", help_text) + + +class GroupedBranchHelpOutputTest(unittest.TestCase): + """Tests for the grouped section headers in `poly branch --help`.""" + + def setUp(self): + cli = AgentStudioCLI() + cli.register_commands() + parser = cli._create_parser() + branch_action = [a for a in parser._actions if isinstance(a, _SubParsersAction)][0] + self.help_text = _format_help_at_fixed_width(branch_action.choices["branch"]) + + def test_every_branch_subcommand_is_grouped(self): + """The mapping must cover every registered branch subcommand.""" + cli = AgentStudioCLI() + cli.register_commands() + parser = cli._create_parser() + branch_action = [a for a in parser._actions if isinstance(a, _SubParsersAction)][0] + branch_parser = branch_action.choices["branch"] + registered = [a for a in branch_parser._actions if isinstance(a, _SubParsersAction)][ + 0 + ].choices + + self.assertEqual(set(registered), set(BRANCH_SUBCOMMAND_GROUPS)) + + def test_lifecycle_and_inspect_sections_render(self): + """Both sections appear, lifecycle first.""" + self.assertIn(f"{BRANCH_LIFECYCLE_GROUP}:", self.help_text) + self.assertIn(f"{BRANCH_INSPECT_GROUP}:", self.help_text) + self.assertLess( + self.help_text.index(f"{BRANCH_LIFECYCLE_GROUP}:"), + self.help_text.index(f"{BRANCH_INSPECT_GROUP}:"), + ) + + def test_inspect_section_holds_read_only_subcommands(self): + """diff/review/status are grouped as inspection, not lifecycle.""" + inspect_section = self.help_text.split(f"{BRANCH_INSPECT_GROUP}:")[1] + + for subcommand in ["diff", "review", "status"]: + self.assertIn(subcommand, inspect_section) + for subcommand in ["create", "switch", "merge"]: + self.assertNotIn(f" {subcommand} ", inspect_section) + + def test_description_examples_are_still_raw(self): + """The hand-formatted example block must survive the grouped formatter.""" + self.assertIn(" poly branch create new-branch", self.help_text) + + def test_no_placeholder_or_ragged_whitespace(self): + """Same cleanup guarantees as the root parser.""" + self.assertNotIn("", _help_body(self.help_text)) + self.assertNotIn("positional arguments:", self.help_text) + self.assertNotIn("\n\n\n", self.help_text) + lines = self.help_text.split("\n") + self.assertEqual([line for line in lines if line != line.rstrip()], []) + + +class GroupedFunctionsHelpOutputTest(unittest.TestCase): + """Tests for the grouped section headers in `poly functions --help`.""" + + def _functions_parser(self): + cli = AgentStudioCLI() + cli.register_commands() + parser = cli._create_parser() + root = [a for a in parser._actions if isinstance(a, _SubParsersAction)][0] + return root.choices["functions"] + + def setUp(self): + self.help_text = _format_help_at_fixed_width(self._functions_parser()) + + def test_every_functions_subcommand_is_grouped(self): + """The mapping must cover every registered functions subcommand.""" + functions_parser = self._functions_parser() + registered = [a for a in functions_parser._actions if isinstance(a, _SubParsersAction)][ + 0 + ].choices + + self.assertEqual(set(registered), set(FUNCTIONS_SUBCOMMAND_GROUPS)) + + def test_all_sections_render_in_order(self): + """Every section appears, in the declared order.""" + positions = [] + for title in FUNCTIONS_SUBCOMMAND_GROUP_ORDER: + self.assertIn(f"{title}:", self.help_text) + positions.append(self.help_text.index(f"{title}:")) + + self.assertEqual(positions, sorted(positions)) + + def test_description_examples_are_still_raw(self): + """The hand-formatted example block must survive the grouped formatter.""" + self.assertIn(" poly functions deploy", self.help_text) + + def test_no_placeholder_or_ragged_whitespace(self): + """Same cleanup guarantees as the other grouped parsers.""" + self.assertNotIn("", _help_body(self.help_text)) + self.assertNotIn("positional arguments:", self.help_text) + self.assertNotIn("\n\n\n", self.help_text) + lines = self.help_text.split("\n") + self.assertEqual([line for line in lines if line != line.rstrip()], []) + + class DeploymentsSimplifiedModeTest(unittest.TestCase): """Deployment-mode awareness across the deployments command family. diff --git a/uv.lock b/uv.lock index b5864a67..2f915a98 100644 --- a/uv.lock +++ b/uv.lock @@ -359,7 +359,7 @@ wheels = [ [[package]] name = "polyai-adk" -version = "0.36.3" +version = "0.42.1" source = { editable = "." } dependencies = [ { name = "argcomplete" },