From 33a1d55c982be3999fd43d01e2234a0144892fde Mon Sep 17 00:00:00 2001 From: Atulya Singh Date: Thu, 16 Jul 2026 00:16:07 +0530 Subject: [PATCH] feat(init): add metadata flags for non-interactive project setup `apm init` collected name, version, description and author interactively but exposed no flag for any of them, so scripted and CI init was pinned to boilerplate and had to hand-edit apm.yml afterwards. The description -- the field an interactive user is most likely to customize -- was the one a non-interactive user could not set at all. Add --name, --description, --version and --author. The overrides are applied where the interactive and --yes paths converge on the config dict, so _get_default_config() stays untouched for its other caller in commands/install.py. The new _perform_init() parameters are keyword-only and default to None, leaving `apm plugin init` unchanged. Two details worth noting: - The positional project name also creates and enters a directory, so it keeps precedence over --name, which only labels the manifest. Passing both reports the ignored flag rather than dropping it silently. --name gets the same validation the interactive prompt already enforces. - Plugin mode overwrote config["version"] with 0.1.0 after the config was built, which would have clobbered an explicit --version. It now applies only when no version was given. Outside --yes the flags seed the matching prompt defaults, so a flag is never silently discarded. Closes #2146 --- CHANGELOG.md | 6 + src/apm_cli/commands/init.py | 95 +++++++++++++-- tests/unit/test_init_command.py | 210 ++++++++++++++++++++++++++++++++ 3 files changed, 300 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a48a4341..8f86013c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `apm init` now accepts `--name`, `--description`, `--version`, and `--author`, + so scripted and CI init can write a finished `apm.yml` without hand-editing it + afterwards. `--name` labels the manifest in place and is reported as ignored + when a positional project name is given; outside `--yes` the flags seed the + matching prompt defaults instead of being discarded. (closes #2146) + -- by @atulya-singh - Corporate proxy and internal-CA users can now use Python-based APM HTTPS paths without per-shell TLS setup. APM verifies against the OS trust store through `truststore` for `apm install`, the Python `llm` child runtime, and the frozen diff --git a/src/apm_cli/commands/init.py b/src/apm_cli/commands/init.py index 4dea5a219..e6a1dc5cb 100644 --- a/src/apm_cli/commands/init.py +++ b/src/apm_cli/commands/init.py @@ -86,13 +86,38 @@ def _detect_agentrc(project_root: Path) -> tuple[bool, bool]: default=None, help="Comma-separated target list (skip prompt, write directly)", ) +@click.option( + "--name", + default=None, + help="Project name written to apm.yml. Ignored when a positional project name is given.", +) +@click.option("--description", default=None, help="Project description (default: auto-detected)") +@click.option("--version", default=None, help="Project version (default: 1.0.0)") +@click.option("--author", default=None, help="Project author (default: git config user.name)") @click.option("--verbose", "-v", is_flag=True, help="Show detailed output") @click.pass_context -def init(ctx, project_name, yes, plugin, marketplace_flag, target_flag, verbose): +def init( + ctx, + project_name, + yes, + plugin, + marketplace_flag, + target_flag, + name, + description, + version, + author, + verbose, +): """Initialize a new APM project (like npm init). Creates a minimal apm.yml with auto-detected metadata. + Metadata flags (--name, --description, --version, --author) set the + corresponding apm.yml field without a prompt, so scripted and CI init + can produce a finished manifest. Outside --yes they seed the prompt + defaults rather than being discarded. + Producers: prefer 'apm plugin init' (plugin scaffold) or 'apm marketplace init' (marketplace block). The --plugin and --marketplace flags on 'apm init' are kept for backward @@ -118,6 +143,10 @@ def init(ctx, project_name, yes, plugin, marketplace_flag, target_flag, verbose) plugin=plugin, marketplace_flag=marketplace_flag, target_flag=target_flag, + name=name, + description=description, + version=version, + author=author, verbose=verbose, source="init", ) @@ -131,6 +160,10 @@ def _perform_init( marketplace_flag, target_flag, verbose, + name=None, + description=None, + version=None, + author=None, source="init", ): """Shared init body. Called by `apm init` and `apm plugin init`. @@ -138,6 +171,10 @@ def _perform_init( ``source`` controls the "Next steps" hint shape: - "init" -> consumer-focused, teaches the noun-verb namespace - "plugin" -> plugin-author next steps (same as legacy --plugin) + + ``name``/``description``/``version``/``author`` are the optional + metadata overrides (#2146). They default to ``None`` so callers that + predate them -- ``apm plugin init`` -- keep their existing behavior. """ logger = CommandLogger(source, verbose=verbose) try: @@ -153,6 +190,23 @@ def _perform_init( ) sys.exit(1) + # The positional argument also creates and enters a directory, so it + # keeps precedence over --name, which only labels the manifest. Report + # the ignored flag rather than dropping it silently. + if project_name and name: + logger.warning( + f"--name '{name}' ignored: positional project name " + f"'{project_name}' takes precedence." + ) + name = None + + if name and not _validate_project_name(name): + logger.error( + f"Invalid project name '{name}': " + "project names must not contain path separators ('/' or '\\\\') or be '..'." + ) + sys.exit(1) + # Determine project directory and name if project_name: project_dir = Path(project_name) @@ -162,7 +216,7 @@ def _perform_init( final_project_name = project_name else: project_dir = Path.cwd() - final_project_name = project_dir.name + final_project_name = name or project_dir.name project_root = Path.cwd() # Validate plugin name early @@ -192,10 +246,23 @@ def _perform_init( # Get project configuration (interactive mode or defaults) if not yes: - config = _interactive_project_setup(final_project_name, logger) + config = _interactive_project_setup( + final_project_name, + logger, + description=description, + version=version, + author=author, + ) else: - # Use auto-detected defaults + # Use auto-detected defaults, then let explicit flags win. config = _get_default_config(final_project_name) + for field, override in ( + ("description", description), + ("version", version), + ("author", author), + ): + if override is not None: + config[field] = override # --- Target selection (must run before the confirmation panel so # the chosen targets render in the "About to create" summary). --- @@ -213,8 +280,8 @@ def _perform_init( if not yes: _confirm_setup_summary(config, logger) - # Plugin mode uses 0.1.0 as default version - if plugin and yes: + # Plugin mode uses 0.1.0 as default version, unless --version set one. + if plugin and yes and version is None: config["version"] = "0.1.0" logger.start(f"Initializing APM project: {config['name']}", symbol="running") @@ -369,17 +436,23 @@ def _perform_init( sys.exit(1) -def _interactive_project_setup(default_name, logger): +def _interactive_project_setup( + default_name, logger, *, description=None, version=None, author=None +): """Interactive setup for new APM projects with auto-detection. Collects only the metadata fields here; target selection and final confirmation are run by the caller via ``_confirm_setup_summary`` so targets can be shown in the same "About to create" panel. + + Any metadata passed via flags seeds the matching prompt default, so a + flag given without ``--yes`` is still honored on an empty answer (#2146). """ from ._helpers import _auto_detect_author, _auto_detect_description, _validate_project_name - auto_author = _auto_detect_author() - auto_description = _auto_detect_description(default_name) + auto_author = author or _auto_detect_author() + auto_description = description or _auto_detect_description(default_name) + auto_version = version or "1.0.0" try: from rich.console import Console # type: ignore @@ -398,7 +471,7 @@ def _interactive_project_setup(default_name, logger): "project names must not contain path separators ('/' or '\\\\') or be '..'.[/error]" ) - version = Prompt.ask("Version", default="1.0.0").strip() + version = Prompt.ask("Version", default=auto_version).strip() description = Prompt.ask("Description", default=auto_description).strip() author = Prompt.ask("Author", default=auto_author).strip() @@ -415,7 +488,7 @@ def _interactive_project_setup(default_name, logger): f"project names must not contain path separators ('/' or '\\\\') or be '..'.{RESET}" ) - version = click.prompt("Version", default="1.0.0").strip() + version = click.prompt("Version", default=auto_version).strip() description = click.prompt("Description", default=auto_description).strip() author = click.prompt("Author", default=auto_author).strip() diff --git a/tests/unit/test_init_command.py b/tests/unit/test_init_command.py index 7993b463b..55398ba06 100644 --- a/tests/unit/test_init_command.py +++ b/tests/unit/test_init_command.py @@ -923,3 +923,213 @@ def test_agentrc_suppressed_in_plugin_mode(self): result = self.runner.invoke(cli, ["init", "--plugin", "--yes", "my-plugin"]) assert result.exit_code == 0 assert "agentrc" not in result.output + + +class TestInitMetadataFlags: + """Non-interactive metadata flags for `apm init` (issue #2146).""" + + def setup_method(self): + self.runner = CliRunner() + try: + self.original_dir = os.getcwd() + except FileNotFoundError: + self.original_dir = str(Path(__file__).parent.parent.parent) + os.chdir(self.original_dir) + + def teardown_method(self): + try: + os.chdir(self.original_dir) + except (FileNotFoundError, OSError): + os.chdir(str(Path(__file__).parent.parent.parent)) + + def _read_yaml(self, path="apm.yml"): + with open(path, encoding="utf-8") as f: + return yaml.safe_load(f) + + def test_description_flag_overrides_boilerplate(self): + """--description wins over the auto-detected boilerplate.""" + with tempfile.TemporaryDirectory() as tmp_dir: + os.chdir(tmp_dir) + try: + result = self.runner.invoke( + cli, ["init", "myproj", "--yes", "--description", "My cool project"] + ) + + assert result.exit_code == 0 + config = self._read_yaml(Path(tmp_dir) / "myproj" / "apm.yml") + assert config["description"] == "My cool project" + finally: + os.chdir(self.original_dir) + + def test_version_flag_overrides_default(self): + """--version replaces the hardcoded 1.0.0.""" + with tempfile.TemporaryDirectory() as tmp_dir: + os.chdir(tmp_dir) + try: + result = self.runner.invoke(cli, ["init", "myproj", "--yes", "--version", "0.1.0"]) + + assert result.exit_code == 0 + config = self._read_yaml(Path(tmp_dir) / "myproj" / "apm.yml") + assert config["version"] == "0.1.0" + finally: + os.chdir(self.original_dir) + + def test_author_flag_overrides_git_detection(self): + """--author wins over git config user.name auto-detection.""" + with tempfile.TemporaryDirectory() as tmp_dir: + os.chdir(tmp_dir) + try: + result = self.runner.invoke( + cli, ["init", "myproj", "--yes", "--author", "Jane Dev"] + ) + + assert result.exit_code == 0 + config = self._read_yaml(Path(tmp_dir) / "myproj" / "apm.yml") + assert config["author"] == "Jane Dev" + finally: + os.chdir(self.original_dir) + + def test_all_metadata_flags_together(self): + """The full scripted-init invocation from the issue writes exact values.""" + with tempfile.TemporaryDirectory() as tmp_dir: + os.chdir(tmp_dir) + try: + result = self.runner.invoke( + cli, + [ + "init", + "myproj", + "--yes", + "--description", + "My cool project", + "--version", + "0.1.0", + "--author", + "Jane Dev", + ], + ) + + assert result.exit_code == 0 + config = self._read_yaml(Path(tmp_dir) / "myproj" / "apm.yml") + assert config["name"] == "myproj" + assert config["version"] == "0.1.0" + assert config["description"] == "My cool project" + assert config["author"] == "Jane Dev" + finally: + os.chdir(self.original_dir) + + def test_name_flag_names_project_in_place(self): + """--name sets the manifest name without creating a subdirectory.""" + with tempfile.TemporaryDirectory() as tmp_dir: + os.chdir(tmp_dir) + try: + result = self.runner.invoke(cli, ["init", "--yes", "--name", "custom-name"]) + + assert result.exit_code == 0 + assert Path("apm.yml").exists(), "apm.yml must be written in place" + assert not Path("custom-name").exists(), "--name must not mkdir" + assert self._read_yaml()["name"] == "custom-name" + finally: + os.chdir(self.original_dir) + + def test_name_flag_applies_with_explicit_dot(self): + """`apm init . --name x` is still the in-place case.""" + with tempfile.TemporaryDirectory() as tmp_dir: + os.chdir(tmp_dir) + try: + result = self.runner.invoke(cli, ["init", ".", "--yes", "--name", "dot-name"]) + + assert result.exit_code == 0 + assert self._read_yaml()["name"] == "dot-name" + finally: + os.chdir(self.original_dir) + + def test_positional_arg_wins_over_name_flag_with_warning(self): + """Positional arg keeps its mkdir contract; --name is reported, not dropped.""" + with tempfile.TemporaryDirectory() as tmp_dir: + os.chdir(tmp_dir) + try: + result = self.runner.invoke( + cli, ["init", "myproj", "--yes", "--name", "ignored-name"] + ) + + assert result.exit_code == 0 + config = self._read_yaml(Path(tmp_dir) / "myproj" / "apm.yml") + assert config["name"] == "myproj" + output = " ".join(result.output.split()) + assert "--name" in output and "ignored" in output.lower() + finally: + os.chdir(self.original_dir) + + def test_name_flag_rejects_path_separators(self): + """--name gets the same validation the interactive prompt enforces.""" + with tempfile.TemporaryDirectory() as tmp_dir: + os.chdir(tmp_dir) + try: + result = self.runner.invoke(cli, ["init", "--yes", "--name", "../evil"]) + + assert result.exit_code == 1 + assert not Path("apm.yml").exists() + finally: + os.chdir(self.original_dir) + + def test_flags_seed_interactive_prompts(self): + """Outside --yes the flags seed prompt defaults instead of being dropped.""" + with tempfile.TemporaryDirectory() as tmp_dir: + os.chdir(tmp_dir) + try: + # Accept every default, then: target prompt (y/done) + confirm. + result = self.runner.invoke( + cli, + [ + "init", + "--name", + "seeded-name", + "--version", + "9.9.9", + "--description", + "Seeded description", + "--author", + "Seeded Author", + ], + input="\n\n\n\ny\ndone\ny\n", + ) + + assert result.exit_code == 0 + config = self._read_yaml() + assert config["name"] == "seeded-name" + assert config["version"] == "9.9.9" + assert config["description"] == "Seeded description" + assert config["author"] == "Seeded Author" + finally: + os.chdir(self.original_dir) + + def test_plugin_init_unaffected_by_new_params(self): + """`apm plugin init` does not pass metadata flags; it must still work.""" + with tempfile.TemporaryDirectory() as tmp_dir: + os.chdir(tmp_dir) + try: + result = self.runner.invoke(cli, ["plugin", "init", "my-plugin", "--yes"]) + + assert result.exit_code == 0 + config = self._read_yaml(Path(tmp_dir) / "my-plugin" / "apm.yml") + assert config["name"] == "my-plugin" + # Plugin mode keeps its own 0.1.0 default. + assert config["version"] == "0.1.0" + finally: + os.chdir(self.original_dir) + + def test_explicit_version_survives_plugin_default(self): + """--version must not be clobbered by the plugin-mode 0.1.0 default.""" + with tempfile.TemporaryDirectory() as tmp_dir: + os.chdir(tmp_dir) + try: + result = self.runner.invoke( + cli, ["init", "my-plugin", "--plugin", "--yes", "--version", "2.0.0"] + ) + + assert result.exit_code == 0 + config = self._read_yaml(Path(tmp_dir) / "my-plugin" / "apm.yml") + assert config["version"] == "2.0.0" + finally: + os.chdir(self.original_dir)