diff --git a/docs/src/content/docs/reference/cli-commands.md b/docs/src/content/docs/reference/cli-commands.md index 92707b591..d44c9f785 100644 --- a/docs/src/content/docs/reference/cli-commands.md +++ b/docs/src/content/docs/reference/cli-commands.md @@ -664,17 +664,27 @@ apm deps info design-guidelines Remove the entire `apm_modules/` directory and all installed APM packages. ```bash -apm deps clean +apm deps clean [OPTIONS] ``` +**Options:** +- `--dry-run` - Show what would be removed without removing +- `--yes`, `-y` - Skip confirmation prompt (for non-interactive/scripted use) + **Examples:** ```bash # Remove all APM dependencies (with confirmation) apm deps clean + +# Preview what would be removed +apm deps clean --dry-run + +# Remove without confirmation (e.g. in CI pipelines) +apm deps clean --yes ``` **Behavior:** -- Shows confirmation prompt before deletion +- Shows confirmation prompt before deletion (unless `--yes` is provided) - Removes entire `apm_modules/` directory - Displays count of packages that will be removed - Can be cancelled with Ctrl+C or 'n' response @@ -684,11 +694,11 @@ apm deps clean Update installed APM dependencies to their latest versions. ```bash -apm deps update [PACKAGE_NAME] +apm deps update [PACKAGE] ``` **Arguments:** -- `PACKAGE_NAME` - Optional. Update specific package only +- `PACKAGE` - Optional. Update specific package only **Examples:** ```bash @@ -716,7 +726,7 @@ apm mcp list [OPTIONS] ``` **Options:** -- `--limit INTEGER` - Number of results to show +- `--limit INTEGER` - Number of results to show (default: 20) **Examples:** ```bash @@ -1189,7 +1199,7 @@ apm runtime remove [OPTIONS] {copilot|codex|llm} **Options:** - `--yes` - Confirm the action without prompting -#### `apm runtime status` - Show runtime status +#### `apm runtime status` - Show active runtime and preference order Display which runtime APM will use for execution and runtime preference order. diff --git a/src/apm_cli/commands/deps/_utils.py b/src/apm_cli/commands/deps/_utils.py index 8ab24f89a..16383cece 100644 --- a/src/apm_cli/commands/deps/_utils.py +++ b/src/apm_cli/commands/deps/_utils.py @@ -8,6 +8,29 @@ from ...deps.github_downloader import GitHubPackageDownloader +def _scan_installed_packages(apm_modules_dir: Path) -> list: + """Scan *apm_modules_dir* for installed package paths. + + Walks the tree to find directories containing ``apm.yml`` or ``.apm``, + supporting GitHub (2-level), ADO (3-level), and subdirectory packages. + + Returns: + List of ``"owner/repo"`` or ``"org/project/repo"`` path keys. + """ + installed: list = [] + if not apm_modules_dir.exists(): + return installed + for candidate in apm_modules_dir.rglob("*"): + if not candidate.is_dir() or candidate.name.startswith("."): + continue + if not ((candidate / APM_YML_FILENAME).exists() or (candidate / APM_DIR).exists()): + continue + rel_parts = candidate.relative_to(apm_modules_dir).parts + if len(rel_parts) >= 2: + installed.append("/".join(rel_parts)) + return installed + + def _is_nested_under_package(candidate: Path, apm_modules_path: Path) -> bool: """Check if *candidate* is a sub-directory of another installed package. diff --git a/src/apm_cli/commands/deps/cli.py b/src/apm_cli/commands/deps/cli.py index 2a3b65240..ac115ba48 100644 --- a/src/apm_cli/commands/deps/cli.py +++ b/src/apm_cli/commands/deps/cli.py @@ -397,7 +397,9 @@ def _add_children(parent_branch, parent_repo_url, depth=0): @deps.command(help="Remove all APM dependencies") -def clean(): +@click.option("--dry-run", is_flag=True, default=False, help="Show what would be removed without removing") +@click.option("--yes", "-y", is_flag=True, default=False, help="Skip confirmation prompt") +def clean(dry_run: bool, yes: bool): """Remove entire apm_modules/ directory.""" logger = CommandLogger("deps-clean") @@ -408,21 +410,30 @@ def clean(): logger.progress("No apm_modules/ directory found - already clean") return - # Show what will be removed - package_count = len([d for d in apm_modules_path.iterdir() if d.is_dir()]) + # Count actual installed packages (not just top-level dirs like org namespaces or _local) + from ._utils import _scan_installed_packages + packages = _scan_installed_packages(apm_modules_path) + package_count = len(packages) - logger.warning(f"This will remove the entire apm_modules/ directory ({package_count} packages)") + if dry_run: + logger.progress(f"Dry run: would remove apm_modules/ ({package_count} package(s))") + for pkg in sorted(packages): + logger.progress(f" - {pkg}") + return - # Confirmation prompt - try: - from rich.prompt import Confirm - confirm = Confirm.ask("Continue?") - except ImportError: - confirm = click.confirm("Continue?") + logger.warning(f"This will remove the entire apm_modules/ directory ({package_count} package(s))") - if not confirm: - logger.progress("Operation cancelled") - return + # Confirmation prompt (skip if --yes provided) + if not yes: + try: + from rich.prompt import Confirm + confirm = Confirm.ask("Continue?") + except ImportError: + confirm = click.confirm("Continue?") + + if not confirm: + logger.progress("Operation cancelled") + return try: shutil.rmtree(apm_modules_path) diff --git a/src/apm_cli/commands/install.py b/src/apm_cli/commands/install.py index ed5454850..71786f5e3 100644 --- a/src/apm_cli/commands/install.py +++ b/src/apm_cli/commands/install.py @@ -433,7 +433,7 @@ def _check_repo_fallback(token, git_env): "--dry-run", is_flag=True, help="Show what would be installed without installing" ) @click.option("--force", is_flag=True, help="Overwrite locally-authored files on collision and deploy despite critical security findings") -@click.option("--verbose", is_flag=True, help="Show detailed installation information") +@click.option("--verbose", "-v", is_flag=True, help="Show detailed installation information") @click.option( "--trust-transitive-mcp", is_flag=True, diff --git a/src/apm_cli/commands/mcp.py b/src/apm_cli/commands/mcp.py index bac184d11..4db9f6e4e 100644 --- a/src/apm_cli/commands/mcp.py +++ b/src/apm_cli/commands/mcp.py @@ -292,7 +292,7 @@ def show(ctx, server_name, verbose): @mcp.command(help="List all available MCP servers") -@click.option("--limit", default=20, help="Number of results to show") +@click.option("--limit", default=20, show_default=True, help="Number of results to show") @click.option("--verbose", "-v", is_flag=True, help="Show detailed output") @click.pass_context def list(ctx, limit, verbose): diff --git a/src/apm_cli/commands/pack.py b/src/apm_cli/commands/pack.py index be89dcefc..90ae7e5b0 100644 --- a/src/apm_cli/commands/pack.py +++ b/src/apm_cli/commands/pack.py @@ -23,7 +23,7 @@ "-t", type=click.Choice(["copilot", "vscode", "claude", "cursor", "opencode", "all"]), default=None, - help="Filter files by target (default: auto-detect). 'vscode' is an alias for 'copilot'.", + help="Filter files by target (default: auto-detect). 'copilot' is an alias for 'vscode'.", ) @click.option("--archive", is_flag=True, default=False, help="Produce a .tar.gz archive.") @click.option( diff --git a/src/apm_cli/commands/runtime.py b/src/apm_cli/commands/runtime.py index fb2218fd9..40b0f01de 100644 --- a/src/apm_cli/commands/runtime.py +++ b/src/apm_cli/commands/runtime.py @@ -146,9 +146,9 @@ def remove(runtime_name): sys.exit(1) -@runtime.command(help="Check which runtime will be used") +@runtime.command(help="Show active runtime and preference order") def status(): - """Show which runtime APM will use for execution.""" + """Show active runtime and preference order.""" logger = CommandLogger("runtime status") try: from ..runtime.manager import RuntimeManager diff --git a/tests/unit/test_deps_clean_command.py b/tests/unit/test_deps_clean_command.py new file mode 100644 index 000000000..bb12e6b1f --- /dev/null +++ b/tests/unit/test_deps_clean_command.py @@ -0,0 +1,104 @@ +"""Tests for apm deps clean command --dry-run and --yes flags.""" + +import contextlib +import os +import tempfile +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from apm_cli.cli import cli + + +class TestDepsCleanCommand: + """Tests for apm deps clean --dry-run and --yes flags.""" + + 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): + repo_root = Path(__file__).parent.parent.parent + os.chdir(str(repo_root)) + + @contextlib.contextmanager + def _chdir_tmp(self): + """Create a temp dir, chdir into it, restore CWD on exit.""" + with tempfile.TemporaryDirectory() as tmp_dir: + try: + os.chdir(tmp_dir) + yield Path(tmp_dir) + finally: + os.chdir(self.original_dir) + + def _create_fake_apm_modules(self, root: Path) -> Path: + """Create a fake apm_modules/ with one installed package.""" + pkg_dir = root / "apm_modules" / "testorg" / "testrepo" + pkg_dir.mkdir(parents=True) + (pkg_dir / "apm.yml").write_text("name: testrepo\n") + return root / "apm_modules" + + def test_dry_run_leaves_apm_modules_intact(self): + """--dry-run must not remove apm_modules/.""" + with self._chdir_tmp() as tmp: + apm_modules = self._create_fake_apm_modules(tmp) + + result = self.runner.invoke(cli, ["deps", "clean", "--dry-run"]) + + assert result.exit_code == 0 + assert apm_modules.exists(), "apm_modules/ must not be removed in dry-run mode" + assert "Dry run" in result.output + + def test_dry_run_lists_packages(self): + """--dry-run should show the packages that would be removed.""" + with self._chdir_tmp() as tmp: + self._create_fake_apm_modules(tmp) + + result = self.runner.invoke(cli, ["deps", "clean", "--dry-run"]) + + assert result.exit_code == 0 + assert "testorg/testrepo" in result.output + + def test_yes_flag_skips_confirmation(self): + """--yes must remove apm_modules/ without an interactive prompt.""" + with self._chdir_tmp() as tmp: + apm_modules = self._create_fake_apm_modules(tmp) + + result = self.runner.invoke(cli, ["deps", "clean", "--yes"]) + + assert result.exit_code == 0 + assert not apm_modules.exists(), "apm_modules/ must be removed when --yes is used" + + def test_yes_short_flag_skips_confirmation(self): + """-y short flag must also skip confirmation.""" + with self._chdir_tmp() as tmp: + apm_modules = self._create_fake_apm_modules(tmp) + + result = self.runner.invoke(cli, ["deps", "clean", "-y"]) + + assert result.exit_code == 0 + assert not apm_modules.exists() + + def test_no_apm_modules_reports_already_clean(self): + """When apm_modules/ does not exist the command should exit cleanly.""" + with self._chdir_tmp(): + result = self.runner.invoke(cli, ["deps", "clean"]) + + assert result.exit_code == 0 + assert "already clean" in result.output + + def test_dry_run_no_apm_modules_reports_already_clean(self): + """--dry-run with no apm_modules/ should also exit cleanly.""" + with self._chdir_tmp(): + result = self.runner.invoke(cli, ["deps", "clean", "--dry-run"]) + + assert result.exit_code == 0 + assert "already clean" in result.output diff --git a/uv.lock b/uv.lock index 5ad4ef367..0683442d8 100644 --- a/uv.lock +++ b/uv.lock @@ -179,7 +179,7 @@ wheels = [ [[package]] name = "apm-cli" -version = "0.8.3" +version = "0.8.4" source = { editable = "." } dependencies = [ { name = "click" },