diff --git a/src/poly/cli.py b/src/poly/cli.py index 3f20b42..8ee7a94 100644 --- a/src/poly/cli.py +++ b/src/poly/cli.py @@ -180,6 +180,43 @@ def _create_parser(cls) -> ArgumentParser: ) init_parser.add_argument("--debug", action="store_true", help="Display debug logs.") + # CREATE-PROJECT + create_project_parser = subparsers.add_parser( + "create-project", + parents=[verbose_parent, json_parent], + help="Create a new Agent Studio project under an account.", + description=( + "Create a new Agent Studio project under an interactively selected account.\n\n" + "Examples:\n" + " poly create-project\n" + " poly create-project --region us-1 --account_id my-account --name my-project\n" + ), + formatter_class=RawTextHelpFormatter, + ) + create_project_parser.add_argument( + "--base-path", + type=str, + default=os.getcwd(), + help="Base path to initialize the project. Defaults to current working directory.", + ) + create_project_parser.add_argument( + "--region", + type=str, + choices=REGIONS, + help="Region for the Agent Studio project.", + ) + create_project_parser.add_argument( + "--account_id", + type=str, + help="Account ID for the Agent Studio project.", + ) + create_project_parser.add_argument( + "--name", + type=str, + dest="project_name", + help="Name for the new project.", + ) + # PULL pull_parser = subparsers.add_parser( "pull", @@ -673,6 +710,15 @@ def _run_command(cls, args): output_json_projection=args.output_json_projection, ) + elif args.command == "create-project": + cls.create_project( + args.base_path, + region=args.region, + account_id=args.account_id, + project_name=args.project_name, + output_json=args.json, + ) + elif args.command == "pull": cls.pull( args.path, @@ -899,6 +945,90 @@ def read_project_config(cls, base_path: str) -> AgentStudioProject: # Recurse into parent directory return cls.read_project_config(parent_path) + @classmethod + def create_project( + cls, + base_path: str, + region: str = None, + account_id: str = None, + project_name: str = None, + output_json: bool = False, + ) -> None: + """Create a new Agent Studio project under an interactively selected account.""" + if output_json and not (region and account_id and project_name): + json_print( + { + "success": False, + "error": ( + "create-project with --json requires --region, --account_id, and --name." + ), + } + ) + sys.exit(1) + + if not region: + region = questionary.select("Select Region", choices=REGIONS).ask() + if not region: + warning("No region selected. Exiting.") + return + + api_handler = AgentStudioInterface() + + if not account_id: + accounts = api_handler.get_accounts(region) + if not accounts: + error("No accounts found for this region.") + return + account_menu = questionary.select( + "Select Account", + choices=list(accounts.keys()), + use_search_filter=True, + use_jk_keys=False, + ).ask() + if not account_menu: + warning("No account selected. Exiting.") + return + account_id = accounts[account_menu] + + if not project_name: + project_name = questionary.text("Enter project name:").ask() + if not project_name or not project_name.strip(): + warning("No project name provided. Exiting.") + return + project_name = project_name.strip() + + if not output_json: + info(f"Creating project [bold]{project_name}[/bold] under account {account_id}...") + + try: + result = api_handler.create_project(region, account_id, project_name) + except Exception as e: + if output_json: + json_print({"success": False, "error": str(e)}) + else: + error(f"Failed to create project: {e}") + return + + project_id = result.get("id") + if not project_id: + if output_json: + json_print({"success": False, "error": "No project ID returned by API."}) + else: + error("No project ID returned by API.") + return + + if not output_json: + success(f"Created project [bold]{project_name}[/bold] ({project_id})") + info("Initializing project locally...") + + cls.init_project( + base_path, + region=region, + account_id=account_id, + project_id=project_id, + output_json=output_json, + ) + @classmethod def init_project( cls, diff --git a/src/poly/handlers/interface.py b/src/poly/handlers/interface.py index 400d387..5eba7a6 100644 --- a/src/poly/handlers/interface.py +++ b/src/poly/handlers/interface.py @@ -72,6 +72,26 @@ def get_projects(region: str, account_id: str) -> dict[str, str]: """ return PlatformAPIHandler.get_projects(region, account_id) + @staticmethod + def create_project( + region: str, + account_id: str, + project_name: str, + project_id: str = None, + ) -> dict[str, str]: + """Create a new project in an account. + + Args: + region (str): The region name + account_id (str): The account ID + project_name (str): The display name for the new project + project_id (str | None): Optional slug/ID for the project + + Returns: + dict[str, str]: A dictionary with the created project's 'id' and 'name' + """ + return PlatformAPIHandler.create_project(region, account_id, project_name, project_id) + @staticmethod def get_deployments(region: str, account_id: str, project_id: str) -> dict[str, str]: """Get the deployments for a given project. diff --git a/src/poly/handlers/platform_api.py b/src/poly/handlers/platform_api.py index abd7513..9ff5be6 100644 --- a/src/poly/handlers/platform_api.py +++ b/src/poly/handlers/platform_api.py @@ -37,6 +37,14 @@ class PlatformAPIHandler: "us-1": "https://api.us.poly.ai/adk/v1", } + region_to_sourcerer_url = { + "dev": "https://sourcerer.dev.platform.polyai.app/api/v1", + "staging": "https://sourcerer.staging.platform.polyai.app/api/v1", + "euw-1": "https://sourcerer.euw-1.platform.polyai.app/api/v1", + "uk-1": "https://sourcerer.uk-1.platform.polyai.app/api/v1", + "us-1": "https://sourcerer.us-1.platform.polyai.app/api/v1", + } + @staticmethod def get_base_url(region: str) -> str: """Get the base URL for the Platform API based on the region. @@ -50,6 +58,19 @@ def get_base_url(region: str) -> str: return base_url raise ValueError(f"Unknown region: {region}") + @staticmethod + def get_sourcerer_url(region: str) -> str: + """Get the Sourcerer API base URL for the given region. + + Args: + region (str): The region name + Returns: + str: The Sourcerer API base URL + """ + if base_url := PlatformAPIHandler.region_to_sourcerer_url.get(region): + return base_url + raise ValueError(f"Unknown region: {region}") + @staticmethod def _retrieve_api_key() -> str: """Get API key from environment""" @@ -168,6 +189,74 @@ def get_projects(region: str, account_id: str) -> dict[str, str]: return projects + @staticmethod + def create_project( + region: str, + account_id: str, + project_name: str, + project_id: str = None, + ) -> dict[str, str]: + """Create a new project in an account via the Sourcerer API. + + Args: + region (str): The region name + account_id (str): The account ID + project_name (str): The display name for the new project + project_id (str | None): Optional slug/ID for the project. + Defaults to a slugified version of the project name. + + Returns: + dict[str, str]: A dictionary with the created project's 'id' and 'name' + """ + if not project_id: + project_id = project_name.lower().replace(" ", "-") + + endpoint = PROJECTS_URL.format(account_id=account_id) + url = PlatformAPIHandler.get_sourcerer_url(region) + endpoint + data = { + "name": project_name, + "project_id": project_id, + "config": { + "voice_id": "VOICE-afe2b8e8", + "model_id": "MODEL-27a9c7af", + "config": {"language_code": "en-US"}, + }, + "topic_names": [], + "knowledge_base": { + "welcome_message": "Hello, how can I help you?", + "additional_context": {}, + "knowledge_base": {"rules": {"behaviour": ""}}, + }, + } + + correlation_id = f"adk-{uuid.uuid4()}" + headers = { + "X-API-KEY": PlatformAPIHandler._retrieve_api_key(), + "X-PolyAI-Correlation-Id": correlation_id, + "Content-Type": "application/json", + } + + logger.info(f"Creating project at {url}") + response = requests.request( + method="POST", + url=url, + headers=headers, + allow_redirects=False, + data=json.dumps(data), + ) + + try: + response.raise_for_status() + except requests.HTTPError: + logger.debug( + f"Error creating project. url={url!r} status_code={response.status_code!r}" + f" response={response.text!r}" + ) + raise + + result = response.json() + return {"id": result.get("id"), "name": result.get("name")} + @staticmethod def get_deployments(region: str, account_id: str, project_id: str) -> dict[str, str]: """Get the deployments for a given project. diff --git a/src/poly/tests/cli_test.py b/src/poly/tests/cli_test.py index 27d1f29..3b309ff 100644 --- a/src/poly/tests/cli_test.py +++ b/src/poly/tests/cli_test.py @@ -334,3 +334,234 @@ def test_completion_invalid_shell_rejected_by_parser(self): """Parser rejects shell choices outside bash/zsh/fish.""" with self.assertRaises(SystemExit): AgentStudioCLI.main(sys_args=["completion", "powershell"]) + + +class CreateProjectTest(unittest.TestCase): + """Tests for the create-project command.""" + + @patch("poly.cli.AgentStudioCLI.init_project") + @patch("poly.cli.AgentStudioInterface") + def test_non_interactive_creates_project_and_inits(self, mock_iface_cls, mock_init): + """create-project with all args provided creates the project and inits locally.""" + mock_iface = mock_iface_cls.return_value + mock_iface.create_project.return_value = {"id": "proj-123", "name": "my-project"} + + AgentStudioCLI.create_project( + TEST_DIR, + region="us-1", + account_id="acc-456", + project_name="my-project", + output_json=False, + ) + + mock_iface.create_project.assert_called_once_with("us-1", "acc-456", "my-project") + mock_init.assert_called_once_with( + TEST_DIR, + region="us-1", + account_id="acc-456", + project_id="proj-123", + output_json=False, + ) + + @patch("poly.cli.AgentStudioCLI.init_project") + @patch("poly.cli.AgentStudioInterface") + @patch("poly.cli.questionary") + def test_interactive_flow_selects_region_account_and_name( + self, mock_q, mock_iface_cls, mock_init + ): + """create-project with no args prompts for region, account, and name.""" + mock_q.select.return_value.ask.side_effect = ["us-1", "My Account"] + mock_q.text.return_value.ask.return_value = "new-project" + + mock_iface = mock_iface_cls.return_value + mock_iface.get_accounts.return_value = {"My Account": "acc-789"} + mock_iface.create_project.return_value = {"id": "proj-001", "name": "new-project"} + + AgentStudioCLI.create_project( + TEST_DIR, + region=None, + account_id=None, + project_name=None, + output_json=False, + ) + + mock_iface.get_accounts.assert_called_once_with("us-1") + mock_iface.create_project.assert_called_once_with("us-1", "acc-789", "new-project") + mock_init.assert_called_once_with( + TEST_DIR, + region="us-1", + account_id="acc-789", + project_id="proj-001", + output_json=False, + ) + + @patch("poly.cli.AgentStudioInterface") + def test_json_mode_requires_all_args(self, mock_iface_cls): + """create-project --json without all args exits with error.""" + with self.assertRaises(SystemExit) as ctx: + AgentStudioCLI.create_project( + TEST_DIR, + region="us-1", + account_id=None, + project_name=None, + output_json=True, + ) + + self.assertEqual(ctx.exception.code, 1) + mock_iface_cls.return_value.create_project.assert_not_called() + + @patch("poly.cli.AgentStudioCLI.init_project") + @patch("poly.cli.AgentStudioInterface") + @patch("poly.cli.questionary") + def test_user_cancels_region_selection(self, mock_q, mock_iface_cls, mock_init): + """create-project returns early when user cancels region selection.""" + mock_q.select.return_value.ask.return_value = None + + AgentStudioCLI.create_project( + TEST_DIR, + region=None, + account_id=None, + project_name=None, + output_json=False, + ) + + mock_iface_cls.return_value.create_project.assert_not_called() + mock_init.assert_not_called() + + @patch("poly.cli.AgentStudioCLI.init_project") + @patch("poly.cli.AgentStudioInterface") + @patch("poly.cli.questionary") + def test_user_cancels_account_selection(self, mock_q, mock_iface_cls, mock_init): + """create-project returns early when user cancels account selection.""" + mock_q.select.return_value.ask.side_effect = ["us-1", None] + + mock_iface = mock_iface_cls.return_value + mock_iface.get_accounts.return_value = {"Acme Corp": "acc-100"} + + AgentStudioCLI.create_project( + TEST_DIR, + region=None, + account_id=None, + project_name=None, + output_json=False, + ) + + mock_iface.create_project.assert_not_called() + mock_init.assert_not_called() + + @patch("poly.cli.AgentStudioCLI.init_project") + @patch("poly.cli.AgentStudioInterface") + @patch("poly.cli.questionary") + def test_user_cancels_project_name_entry(self, mock_q, mock_iface_cls, mock_init): + """create-project returns early when user enters empty project name.""" + mock_q.select.return_value.ask.side_effect = ["us-1", "My Account"] + mock_q.text.return_value.ask.return_value = "" + + mock_iface = mock_iface_cls.return_value + mock_iface.get_accounts.return_value = {"My Account": "acc-200"} + + AgentStudioCLI.create_project( + TEST_DIR, + region=None, + account_id=None, + project_name=None, + output_json=False, + ) + + mock_iface.create_project.assert_not_called() + mock_init.assert_not_called() + + @patch("poly.cli.AgentStudioCLI.init_project") + @patch("poly.cli.AgentStudioInterface") + def test_api_error_during_creation_reports_failure(self, mock_iface_cls, mock_init): + """create-project reports error and does not init when API call fails.""" + mock_iface = mock_iface_cls.return_value + mock_iface.create_project.side_effect = RuntimeError("API is down") + + AgentStudioCLI.create_project( + TEST_DIR, + region="us-1", + account_id="acc-300", + project_name="bad-project", + output_json=False, + ) + + mock_iface.create_project.assert_called_once() + mock_init.assert_not_called() + + @patch("poly.cli.AgentStudioCLI.init_project") + @patch("poly.cli.AgentStudioInterface") + def test_api_error_in_json_mode_returns_error_json(self, mock_iface_cls, mock_init): + """create-project --json returns error JSON when API call fails.""" + mock_iface = mock_iface_cls.return_value + mock_iface.create_project.side_effect = RuntimeError("Server error") + + AgentStudioCLI.create_project( + TEST_DIR, + region="us-1", + account_id="acc-400", + project_name="failing-project", + output_json=True, + ) + + mock_init.assert_not_called() + + @patch("poly.cli.AgentStudioCLI.init_project") + @patch("poly.cli.AgentStudioInterface") + @patch("poly.cli.questionary") + def test_no_accounts_found_returns_early(self, mock_q, mock_iface_cls, mock_init): + """create-project returns early when no accounts exist for the region.""" + mock_q.select.return_value.ask.return_value = "us-1" + + mock_iface = mock_iface_cls.return_value + mock_iface.get_accounts.return_value = {} + + AgentStudioCLI.create_project( + TEST_DIR, + region=None, + account_id=None, + project_name=None, + output_json=False, + ) + + mock_iface.create_project.assert_not_called() + mock_init.assert_not_called() + + @patch("poly.cli.AgentStudioCLI.init_project") + @patch("poly.cli.AgentStudioInterface") + def test_no_project_id_in_response_does_not_init(self, mock_iface_cls, mock_init): + """create-project does not init when API response has no project ID.""" + mock_iface = mock_iface_cls.return_value + mock_iface.create_project.return_value = {"id": None, "name": "orphan"} + + AgentStudioCLI.create_project( + TEST_DIR, + region="us-1", + account_id="acc-500", + project_name="orphan", + output_json=False, + ) + + mock_init.assert_not_called() + + @patch("poly.cli.AgentStudioCLI.init_project") + @patch("poly.cli.AgentStudioInterface") + @patch("poly.cli.questionary") + def test_project_name_is_stripped(self, mock_q, mock_iface_cls, mock_init): + """create-project strips whitespace from interactively entered project name.""" + mock_q.select.return_value.ask.side_effect = ["euw-1", "My Account"] + mock_q.text.return_value.ask.return_value = " spaced-name " + + mock_iface = mock_iface_cls.return_value + mock_iface.get_accounts.return_value = {"My Account": "acc-600"} + mock_iface.create_project.return_value = {"id": "proj-007", "name": "spaced-name"} + + AgentStudioCLI.create_project( + TEST_DIR, + region=None, + account_id=None, + project_name=None, + output_json=False, + ) + + mock_iface.create_project.assert_called_once_with("euw-1", "acc-600", "spaced-name")