diff --git a/docs/src/content/docs/reference/cli-commands.md b/docs/src/content/docs/reference/cli-commands.md index b89c1877..15c27d45 100644 --- a/docs/src/content/docs/reference/cli-commands.md +++ b/docs/src/content/docs/reference/cli-commands.md @@ -330,7 +330,7 @@ apm pack [OPTIONS] **Options:** - `-o, --output PATH` - Output directory (default: `./build`) -- `-t, --target [copilot|vscode|claude|all]` - Filter files by target. Auto-detects from `apm.yml` if not specified. `vscode` is an alias for `copilot` +- `-t, --target [vscode|copilot|claude|all]` - Filter files by target. Auto-detects from `apm.yml` if not specified. `copilot` is an alias for `vscode` - `--archive` - Produce a `.tar.gz` archive instead of a directory - `--dry-run` - List files that would be packed without writing anything - `--format [apm|plugin]` - Bundle format (default: `apm`) @@ -567,17 +567,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 @@ -587,11 +597,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 @@ -619,7 +629,7 @@ apm mcp list [OPTIONS] ``` **Options:** -- `--limit INTEGER` - Number of results to show +- `--limit INTEGER` - Number of results to show (default: 20) **Examples:** ```bash @@ -1088,7 +1098,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.py b/src/apm_cli/commands/deps.py index 17f4379f..8995f741 100644 --- a/src/apm_cli/commands/deps.py +++ b/src/apm_cli/commands/deps.py @@ -390,7 +390,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.""" project_root = Path(".") apm_modules_path = project_root / "apm_modules" @@ -399,21 +401,30 @@ def clean(): _rich_info("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 ._helpers import _scan_installed_packages + packages = _scan_installed_packages(apm_modules_path) + package_count = len(packages) - _rich_warning(f"This will remove the entire apm_modules/ directory ({package_count} packages)") + if dry_run: + _rich_info(f"Dry run: would remove apm_modules/ ({package_count} package(s))") + for pkg in sorted(packages): + _rich_info(f" - {pkg}") + return - # Confirmation prompt - try: - from rich.prompt import Confirm - confirm = Confirm.ask("Continue?") - except ImportError: - confirm = click.confirm("Continue?") + _rich_warning(f"This will remove the entire apm_modules/ directory ({package_count} package(s))") - if not confirm: - _rich_info("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: + _rich_info("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 f3881873..cdd102a1 100644 --- a/src/apm_cli/commands/install.py +++ b/src/apm_cli/commands/install.py @@ -300,7 +300,7 @@ def _validate_package_exists(package): "--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") -@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 26a18f00..57afc6f1 100644 --- a/src/apm_cli/commands/mcp.py +++ b/src/apm_cli/commands/mcp.py @@ -288,7 +288,7 @@ def show(ctx, server_name): @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.pass_context def list(ctx, limit): """List all available MCP servers in the registry.""" diff --git a/src/apm_cli/commands/pack.py b/src/apm_cli/commands/pack.py index 7b3833d4..4d3cdaf3 100644 --- a/src/apm_cli/commands/pack.py +++ b/src/apm_cli/commands/pack.py @@ -21,9 +21,9 @@ @click.option( "--target", "-t", - type=click.Choice(["copilot", "vscode", "claude", "all"]), + type=click.Choice(["vscode", "copilot", "claude", "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 22bbadc8..8bd0fd18 100644 --- a/src/apm_cli/commands/runtime.py +++ b/src/apm_cli/commands/runtime.py @@ -147,9 +147,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.""" 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 00000000..bb12e6b1 --- /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 821fdafa..0026afbf 100644 --- a/uv.lock +++ b/uv.lock @@ -179,7 +179,7 @@ wheels = [ [[package]] name = "apm-cli" -version = "0.7.8" +version = "0.7.9" source = { editable = "." } dependencies = [ { name = "click" },