diff --git a/src/google/adk/cli/cli_create.py b/src/google/adk/cli/cli_create.py index 42bffe29ad..e759df9da6 100644 --- a/src/google/adk/cli/cli_create.py +++ b/src/google/adk/cli/cli_create.py @@ -227,6 +227,16 @@ def run_cmd( except ValueError as exc: raise click.BadParameter(str(exc)) from exc + if not type: + type = _prompt_to_choose_type() + + resolved_type = type.lower() + if resolved_type == "code" and not app_name.isidentifier(): + raise click.BadParameter( + f"Invalid agent name '{app_name}': code-based agents must be a valid" + " Python identifier (e.g. 'my_agent', not 'my-agent')." + ) + agent_folder = os.path.join(os.getcwd(), agent_name) # check folder doesn't exist or it's empty. Otherwise, throw if os.path.exists(agent_folder) and os.listdir(agent_folder): @@ -256,16 +266,13 @@ def run_cmd( google_cloud_project = auth_info.project_id google_cloud_region = auth_info.region - if not type: - type = _prompt_to_choose_type() - _generate_files( agent_folder, google_api_key=google_api_key, google_cloud_project=google_cloud_project, google_cloud_region=google_cloud_region, model=model, - type=type.lower(), + type=resolved_type, ) agents_dir = os.path.dirname(os.path.normpath(agent_name)) click.echo( diff --git a/tests/unittests/cli/utils/test_cli_create.py b/tests/unittests/cli/utils/test_cli_create.py index f1d7ed06b9..a1588a96d3 100644 --- a/tests/unittests/cli/utils/test_cli_create.py +++ b/tests/unittests/cli/utils/test_cli_create.py @@ -256,6 +256,123 @@ def test_run_cmd_invalid_app_name( ) +def test_run_cmd_rejects_non_identifier_agent_name( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Kebab-case should be rejected before any dialogs.""" + monkeypatch.setattr(os, "getcwd", lambda: str(tmp_path)) + model_prompt_called = False + + def _fail_if_prompted(): + nonlocal model_prompt_called + model_prompt_called = True + return "gemini-2.5-flash" + + monkeypatch.setattr(cli_create, "_prompt_for_model", _fail_if_prompted) + + with pytest.raises( + click.BadParameter, match="must be a valid Python identifier" + ): + cli_create.run_cmd( + "my-agent", + model=None, + google_api_key=None, + google_cloud_project=None, + google_cloud_region=None, + type="code", + ) + assert not model_prompt_called + + +@pytest.mark.parametrize( + "valid_name", + [ + "my_agent", + "MyAgent", + "myAgent", + "class", + ], +) +def test_run_cmd_accepts_valid_identifier_names( + valid_name: str, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """snake_case, PascalCase, camelCase, and keywords should be accepted.""" + monkeypatch.setattr(os, "getcwd", lambda: str(tmp_path)) + + cli_create.run_cmd( + valid_name, + model="gemini-2.5-flash", + google_api_key="test-key", + google_cloud_project=None, + google_cloud_region=None, + type="code", + ) + assert (tmp_path / valid_name / "agent.py").exists() + + +def test_run_cmd_and_load_keyword_agent( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Creating a keyword-named application succeeds and AgentLoader can load it.""" + from google.adk.agents.llm_agent import LlmAgent + from google.adk.cli.utils.agent_loader import AgentLoader + + monkeypatch.setattr(os, "getcwd", lambda: str(tmp_path)) + + cli_create.run_cmd( + "class", + model="gemini-2.5-flash", + google_api_key="offline-placeholder", + google_cloud_project=None, + google_cloud_region=None, + type="code", + ) + loader = AgentLoader(str(tmp_path)) + agent = loader.load_agent("class") + assert isinstance(agent, LlmAgent) + assert agent.name == "root_agent" + + +def test_run_cmd_allows_kebab_case_for_config_agents( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Config-based agents do not rely on Python imports and allow kebab-case.""" + monkeypatch.setattr(os, "getcwd", lambda: str(tmp_path)) + + cli_create.run_cmd( + "my-config-agent", + model="gemini-2.5-flash", + google_api_key="test-key", + google_cloud_project=None, + google_cloud_region=None, + type="config", + ) + config_file = tmp_path / "my-config-agent" / "root_agent.yaml" + assert config_file.exists() + content = config_file.read_text(encoding="utf-8") + assert "model: gemini-2.5-flash" in content + + +def test_run_cmd_rejects_kebab_case_when_prompted_as_code( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """If type is not passed, choosing CODE in prompt still validates identifier.""" + monkeypatch.setattr(os, "getcwd", lambda: str(tmp_path)) + monkeypatch.setattr(cli_create, "_prompt_to_choose_type", lambda: "CODE") + + with pytest.raises( + click.BadParameter, match="must be a valid Python identifier" + ): + cli_create.run_cmd( + "kebab-agent", + model="gemini-2.5-flash", + google_api_key="test-key", + google_cloud_project=None, + google_cloud_region=None, + type=None, + ) + + def test_run_cmd_with_type_config( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: