Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).


## [0.6.3] - 2025-12-09

### Fixed

- **Selective Package Install**: `apm install <package>` now only installs the specified package instead of all packages from apm.yml. Previously, installing a single package would also install unrelated packages. `apm install` (no args) continues to install all packages from the manifest.

## [0.6.2] - 2025-12-09

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "apm-cli"
version = "0.6.2"
version = "0.6.3"
description = "MCP configuration tool"
readme = "README.md"
requires-python = ">=3.9"
Expand Down
34 changes: 30 additions & 4 deletions src/apm_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -635,7 +635,10 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, verbose):
sys.exit(1)

try:
apm_count, prompt_count, agent_count = _install_apm_dependencies(apm_package, update, verbose)
# If specific packages were requested, only install those
# Otherwise install all from apm.yml
only_pkgs = builtins.list(packages) if packages else None
apm_count, prompt_count, agent_count = _install_apm_dependencies(apm_package, update, verbose, only_pkgs)
Comment thread
danielmeppiel marked this conversation as resolved.
except Exception as e:
_rich_error(f"Failed to install APM dependencies: {e}")
sys.exit(1)
Expand Down Expand Up @@ -1059,20 +1062,21 @@ def uninstall(ctx, packages, dry_run):
sys.exit(1)


def _install_apm_dependencies(apm_package: "APMPackage", update_refs: bool = False, verbose: bool = False):
def _install_apm_dependencies(apm_package: "APMPackage", update_refs: bool = False, verbose: bool = False, only_packages: "builtins.list" = None):
"""Install APM package dependencies.

Args:
apm_package: Parsed APM package with dependencies
update_refs: Whether to update existing packages to latest refs
verbose: Show detailed installation information
only_packages: If provided, only install these specific packages (not all from apm.yml)
"""
if not APM_DEPS_AVAILABLE:
raise RuntimeError("APM dependency system not available")

apm_deps = apm_package.get_apm_dependencies()
if not apm_deps:
return
return 0, 0, 0

_rich_info(f"Installing APM dependencies ({len(apm_deps)})...")

Expand All @@ -1095,9 +1099,31 @@ def _install_apm_dependencies(apm_package: "APMPackage", update_refs: bool = Fal
flat_deps = dependency_graph.flattened_dependencies
deps_to_install = flat_deps.get_installation_list()

# If specific packages were requested, filter to only those
if only_packages:
# Normalize package strings for comparison
# User passes "owner/repo" or "owner/repo/subdir"
# str(dep) includes host: "github.com/owner/repo/subdir"
# dep.repo_url is just "owner/repo" (no subdir)
# We need to match the user input against the dep string (without host prefix)
only_set = builtins.set(only_packages)

def matches_filter(dep):
# Check exact match with str(dep)
if str(dep) in only_set:
return True
# Check if str(dep) ends with the user-provided package (handles host prefix)
dep_str = str(dep)
for pkg in only_set:
if dep_str.endswith(pkg) or dep_str.endswith(f"/{pkg}"):
Comment thread
danielmeppiel marked this conversation as resolved.
Outdated
return True
return False

deps_to_install = [dep for dep in deps_to_install if matches_filter(dep)]

if not deps_to_install:
_rich_info("No APM dependencies to install", symbol="check")
return
return 0, 0, 0

# Create apm_modules directory
apm_modules_dir = project_root / "apm_modules"
Expand Down
103 changes: 103 additions & 0 deletions tests/unit/test_selective_install.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""Tests for selective package installation (apm install <specific-package>).

This tests the fix where `apm install <package>` should only install that specific
package, while `apm install` (no args) installs all packages from apm.yml.

Bug context: Previously, running `apm install ComposioHQ/awesome-claude-skills/mcp-builder`
would also install unrelated packages like `design-guidelines` from apm.yml.
"""
Comment thread
danielmeppiel marked this conversation as resolved.

import builtins
import pytest
Comment thread
danielmeppiel marked this conversation as resolved.
Outdated


class TestFilterMatchingLogic:
"""Test the filter matching logic used in _install_apm_dependencies.

This replicates the exact filter logic from cli.py to ensure it correctly
handles the host prefix mismatch issue (user passes 'owner/repo' but
str(dep) returns 'github.com/owner/repo').
"""

def _matches_filter(self, dep_str: str, only_packages: list) -> bool:
"""Replicate the filter logic from cli.py for testing."""
only_set = builtins.set(only_packages)

# Check exact match
if dep_str in only_set:
return True
# Check if dep_str ends with the user-provided package (handles host prefix)
for pkg in only_set:
if dep_str.endswith(pkg) or dep_str.endswith(f"/{pkg}"):
Comment thread
danielmeppiel marked this conversation as resolved.
Outdated
return True
return False

def test_exact_match(self):
"""Test exact string match."""
dep_str = "owner/repo"
assert self._matches_filter(dep_str, ["owner/repo"])

def test_host_prefix_match(self):
"""Test matching when dep has host prefix (the main bug case)."""
dep_str = "github.com/owner/repo"
assert self._matches_filter(dep_str, ["owner/repo"])

def test_virtual_package_match(self):
"""Test matching virtual packages with subdirectory paths."""
dep_str = "github.com/ComposioHQ/awesome-claude-skills/mcp-builder"
assert self._matches_filter(dep_str, ["ComposioHQ/awesome-claude-skills/mcp-builder"])

def test_non_match(self):
"""Test that non-matching packages don't match."""
dep_str = "github.com/owner2/repo2"
assert not self._matches_filter(dep_str, ["owner1/repo1"])

def test_partial_repo_name_does_not_match(self):
"""Test that partial repo names don't cause false positives."""
# If user wants 'owner/repo', it shouldn't match 'other-owner/repo'
dep_str = "github.com/owner1/repo1"
assert not self._matches_filter(dep_str, ["owner2/repo2"])
Comment thread
danielmeppiel marked this conversation as resolved.

def test_multiple_packages_in_filter(self):
"""Test filter with multiple packages requested."""
filter_list = ["owner1/repo1", "owner2/repo2"]

assert self._matches_filter("github.com/owner1/repo1", filter_list)
assert self._matches_filter("github.com/owner2/repo2", filter_list)
assert not self._matches_filter("github.com/owner3/repo3", filter_list)

def test_real_bug_case_mcp_builder_vs_design_guidelines(self):
"""Test the exact bug case: user wants mcp-builder, not design-guidelines.

This is the test that would have caught the original bug.
"""
filter_list = ["ComposioHQ/awesome-claude-skills/mcp-builder"]

# Should match mcp-builder
assert self._matches_filter(
"github.com/ComposioHQ/awesome-claude-skills/mcp-builder",
filter_list
)

# Should NOT match design-guidelines
assert not self._matches_filter(
"github.com/danielmeppiel/design-guidelines",
filter_list
)

def test_github_enterprise_host(self):
"""Test matching with GitHub Enterprise hosts."""
dep_str = "ghe.company.com/owner/repo"
assert self._matches_filter(dep_str, ["owner/repo"])

def test_azure_devops_host(self):
"""Test matching with Azure DevOps hosts."""
dep_str = "dev.azure.com/org/project/repo"
# This should match if user passes the full path
assert self._matches_filter(dep_str, ["org/project/repo"])

def test_empty_filter_matches_nothing(self):
"""Test that empty filter matches nothing."""
# This shouldn't happen in practice, but let's be safe
assert not self._matches_filter("github.com/owner/repo", [])

Loading