Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `apm install` no longer silently drops skills, agents, and commands when a Claude Code plugin also ships `hooks/*.json`. The package-type detection cascade now classifies plugin-shaped packages as `MARKETPLACE_PLUGIN` (which already maps hooks via the plugin synthesizer) before falling back to the hook-only classification, and emits a default-visibility `[!]` warning when a hook-only classification disagrees with the package's directory contents (#780)
- Preserve custom git ports across protocols: non-default ports on `ssh://` and `https://` dependency URLs (e.g. Bitbucket Datacenter on SSH port 7999, self-hosted GitLab on HTTPS port 8443) are now captured as a first-class `port` field on `DependencyReference` and threaded through all clone URL builders. When the SSH clone fails, the HTTPS fallback reuses the same port instead of silently dropping it (#661, #731)
- Detect port-like first path segment in SCP shorthand (`git@host:7999/path`) and raise an actionable error suggesting the `ssh://` URL form, instead of silently misparsing the port as part of the repository path (#784)
- Fix `apm init` showing overwrite confirmation prompt three times on Windows CP950 terminals (#602)

## [0.8.12] - 2026-04-19

Expand Down
10 changes: 0 additions & 10 deletions src/apm_cli/commands/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,16 +100,6 @@ def _lazy_prompt():
return None


def _lazy_confirm():
"""Lazy import for Rich Confirm to improve startup performance."""
try:
from rich.prompt import Confirm

return Confirm
except ImportError:
return None


# ------------------------------------------------------------------
# Shared orphan-detection helpers
# ------------------------------------------------------------------
Expand Down
7 changes: 2 additions & 5 deletions src/apm_cli/commands/deps/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -450,11 +450,8 @@ def clean(dry_run: bool, yes: bool):

# 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?")
# Plain click.confirm: Rich Confirm.ask triple-prompts on CP950 terminals; see #602
confirm = click.confirm("Continue?")

if not confirm:
logger.progress("Operation cancelled")
Expand Down
16 changes: 5 additions & 11 deletions src/apm_cli/commands/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
_create_plugin_json,
_get_console,
_get_default_config,
_lazy_confirm,
_rich_blank_line,
_validate_plugin_name,
_validate_project_name,
Expand Down Expand Up @@ -84,14 +83,8 @@ def init(ctx, project_name, yes, plugin, verbose):
logger.warning("apm.yml already exists")

if not yes:
Confirm = _lazy_confirm()
if Confirm:
try:
confirm = Confirm.ask("Continue and overwrite?")
except Exception:
confirm = click.confirm("Continue and overwrite?")
else:
confirm = click.confirm("Continue and overwrite?")
# Plain click.confirm: Rich Confirm.ask triple-prompts on CP950 terminals; see #602
confirm = click.confirm("Continue and overwrite?")

if not confirm:
logger.progress("Initialization cancelled.")
Expand Down Expand Up @@ -182,7 +175,7 @@ def _interactive_project_setup(default_name, logger):
# Lazy import rich pieces
from rich.console import Console # type: ignore
from rich.panel import Panel # type: ignore
from rich.prompt import Confirm, Prompt # type: ignore
from rich.prompt import Prompt # type: ignore

console = _get_console() or Console()
console.print("\n[info]Setting up your APM project...[/info]")
Expand All @@ -209,7 +202,8 @@ def _interactive_project_setup(default_name, logger):
Panel(summary_content, title="About to create", border_style="cyan")
)

if not Confirm.ask("\nIs this OK?", default=True):
# Plain click.confirm: Rich Confirm.ask triple-prompts on CP950 terminals; see #602
if not click.confirm("\nIs this OK?", default=True):
console.print("[info]Aborted.[/info]")
sys.exit(0)

Expand Down
12 changes: 12 additions & 0 deletions tests/unit/test_deps_clean_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import os
import tempfile
from pathlib import Path
from unittest.mock import patch

import pytest
from click.testing import CliRunner
Expand Down Expand Up @@ -102,3 +103,14 @@ def test_dry_run_no_apm_modules_reports_already_clean(self):

assert result.exit_code == 0
assert "already clean" in result.output

def test_clean_confirm_uses_click(self):
"""Test that clean confirmation uses click.confirm, not Rich (#602)."""
with self._chdir_tmp() as tmp:
Path("apm.yml").write_text("name: test-project\nversion: 1.0.0\n")
apm_modules = self._create_fake_apm_modules(tmp)

with patch("apm_cli.commands.deps.cli.click.confirm", return_value=False) as mock_confirm:
result = self.runner.invoke(cli, ["deps", "clean"])
mock_confirm.assert_called_once_with("Continue?")
assert "cancelled" in result.output.lower()
62 changes: 62 additions & 0 deletions tests/unit/test_init_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,68 @@ def test_init_existing_project_interactive_cancel(self):
finally:
os.chdir(self.original_dir) # restore CWD before TemporaryDirectory cleanup

def test_init_existing_project_confirm_prompt_shown_once(self):
"""Test that overwrite confirmation prompt appears exactly once (#602).

On Windows CP950 terminals, Rich Confirm.ask() could fail on encoding,
retry internally, then fall back to click.confirm(), showing the prompt
three times. After the fix, only click.confirm() is used.
"""
with tempfile.TemporaryDirectory() as tmp_dir:
os.chdir(tmp_dir)
try:

# Create existing apm.yml
Path("apm.yml").write_text("name: existing-project\nversion: 0.1.0\n")

# Say yes to overwrite, then provide interactive setup input
user_input = "y\nmy-project\n1.0.0\nA description\nAuthor\ny\n"
result = self.runner.invoke(cli, ["init"], input=user_input)

assert result.exit_code == 0
# The overwrite prompt must appear exactly once
assert result.output.count("Continue and overwrite?") == 1
finally:
os.chdir(self.original_dir) # restore CWD before TemporaryDirectory cleanup

def test_init_existing_project_confirm_uses_click(self):
"""Test that overwrite confirmation uses click.confirm, not Rich (#602)."""
with tempfile.TemporaryDirectory() as tmp_dir:
os.chdir(tmp_dir)
try:

# Create existing apm.yml
Path("apm.yml").write_text("name: existing-project\nversion: 0.1.0\n")

with patch("apm_cli.commands.init.click.confirm", return_value=True) as mock_confirm:
result = self.runner.invoke(cli, ["init", "--yes"])
assert result.exit_code == 0
# --yes skips the prompt entirely, so confirm should NOT be called
mock_confirm.assert_not_called()

with patch("apm_cli.commands.init.click.confirm", return_value=False) as mock_confirm:
result = self.runner.invoke(cli, ["init"])
Comment thread
sergio-sisternes-epam marked this conversation as resolved.
assert result.exit_code == 0
mock_confirm.assert_called_once_with("Continue and overwrite?")
Comment thread
sergio-sisternes-epam marked this conversation as resolved.
assert "Initialization cancelled" in result.output
finally:
os.chdir(self.original_dir) # restore CWD before TemporaryDirectory cleanup

def test_init_interactive_setup_confirm_uses_click(self):
"""Test that interactive setup 'Is this OK?' uses click.confirm, not Rich (#602)."""
with tempfile.TemporaryDirectory() as tmp_dir:
os.chdir(tmp_dir)
try:
with patch("apm_cli.commands.init.click.confirm", return_value=False) as mock_confirm:
# Provide input for the Rich Prompt.ask calls (name, version, desc, author)
result = self.runner.invoke(
cli, ["init"], input="test-project\n1.0.0\nA description\nAuthor\n"
)
assert result.exit_code == 0
mock_confirm.assert_called_once_with("\nIs this OK?", default=True)
finally:
os.chdir(self.original_dir)

def test_init_validates_project_structure(self):
"""Test that init creates minimal project structure."""
with tempfile.TemporaryDirectory() as tmp_dir:
Expand Down
Loading