diff --git a/agentstack/cli/init.py b/agentstack/cli/init.py index ef23981d..9c3cf67b 100644 --- a/agentstack/cli/init.py +++ b/agentstack/cli/init.py @@ -6,7 +6,7 @@ from agentstack import conf, log from agentstack.exceptions import EnvironmentError -from agentstack.utils import is_snake_case +from agentstack.utils import clean_input, is_snake_case from agentstack import packaging from agentstack import frameworks from agentstack import generation @@ -122,6 +122,9 @@ def init_project( if not slug_name: slug_name = prompt_slug_name() + # Keep the filesystem path aligned with the slug used by Cookiecutter. + slug_name = clean_input(slug_name) + conf.set_path(conf.PATH / slug_name) # cookiecutter requires the directory to not exist assert not os.path.exists(conf.PATH), f"Directory already exists: {conf.PATH}" diff --git a/agentstack/utils.py b/agentstack/utils.py index cc2569ed..59d243f2 100644 --- a/agentstack/utils.py +++ b/agentstack/utils.py @@ -86,7 +86,8 @@ def open_yaml_file(path) -> dict: def clean_input(input_string): special_char_pattern = re.compile(r'[^a-zA-Z0-9\s_]') - return re.sub(special_char_pattern, '', input_string).lower().replace(' ', '_').replace('-', '_') + normalized_input = input_string.replace('-', '_') + return re.sub(special_char_pattern, '', normalized_input).lower().replace(' ', '_') def term_color(text: str, color: str) -> str: @@ -131,4 +132,4 @@ def get_base_dir(): except (RuntimeError, OSError, PermissionError): # In CI or when directory is not writable, use temp directory base_dir = Path(os.getenv('TEMP', '/tmp')) - return base_dir \ No newline at end of file + return base_dir diff --git a/tests/test_cli_init.py b/tests/test_cli_init.py index 92ff999d..d917ab03 100644 --- a/tests/test_cli_init.py +++ b/tests/test_cli_init.py @@ -3,6 +3,8 @@ from parameterized import parameterized from pathlib import Path import shutil +import tempfile +from unittest.mock import patch from cli_test_utils import run_cli from agentstack import conf from agentstack import frameworks @@ -32,6 +34,29 @@ def test_init_command(self, template_name: str): self.assertEqual(result.returncode, 0) self.assertTrue((self.project_dir / 'test_project').exists()) + def test_init_command_normalizes_hyphenated_project_name(self): + """The CLI path must match the normalized Cookiecutter project slug.""" + with tempfile.TemporaryDirectory() as temp_dir: + original_path = conf.PATH + conf.set_path(temp_dir) + try: + with patch('agentstack.cli.init.require_uv'), \ + patch('agentstack.cli.init.welcome_message'), \ + patch('agentstack.cli.init.insert_template') as insert_template, \ + patch('agentstack.cli.init.packaging.create_venv'), \ + patch('agentstack.cli.init.packaging.install_project'), \ + patch('agentstack.cli.init.repo.init'), \ + patch('agentstack.cli.init.repo.find_parent_repo', return_value=None), \ + patch('agentstack.cli.init.repo.Transaction') as transaction: + transaction.return_value.__enter__.return_value = transaction.return_value + init_project(slug_name='test-project', template='empty', framework='crewai') + + self.assertEqual(conf.PATH, Path(temp_dir) / 'test_project') + insert_template.assert_called_once() + self.assertEqual(insert_template.call_args.kwargs['name'], 'test_project') + finally: + conf.set_path(original_path) + @parameterized.expand([(k, v) for k, v in frameworks.ALIASED_FRAMEWORKS.items()]) def test_init_command_aliased_framework_empty_project(self, alias: str, framework: str): """Test the 'init' command with an aliased framework.""" diff --git a/tests/test_utils.py b/tests/test_utils.py index a938c41c..52dfdf1a 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -21,6 +21,10 @@ def test_clean_input_remove_space(self): cleaned = clean_input('test project') self.assertEqual('test_project', cleaned) + def test_clean_input_normalize_hyphen(self): + cleaned = clean_input('test-project') + self.assertEqual('test_project', cleaned) + def test_is_snake_case(self): assert is_snake_case("hello_world") assert not is_snake_case("HelloWorld") @@ -69,4 +73,4 @@ def test_get_base_dir_writable(self, mock_user_data_dir): result = get_base_dir() self.assertIsInstance(result, Path) - self.assertTrue(result.is_absolute()) \ No newline at end of file + self.assertTrue(result.is_absolute())