Skip to content
Merged
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
22 changes: 17 additions & 5 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,14 +106,17 @@ apm init my-project --yes

### `apm install` - 📦 Install APM and MCP dependencies

Install APM package and MCP server dependencies from `apm.yml` (like `npm install`).
Install APM package and MCP server dependencies from `apm.yml` (like `npm install`). Auto-creates minimal `apm.yml` when packages are specified but no manifest exists.

```bash
apm install [OPTIONS]
apm install [PACKAGES...] [OPTIONS]
```

**Arguments:**
- `PACKAGES` - Optional APM packages to add and install (format: `owner/repo`)

**Options:**
- `--runtime TEXT` - Target specific runtime only (codex, vscode)
- `--runtime TEXT` - Target specific runtime only (copilot, codex, vscode)
- `--exclude TEXT` - Exclude specific runtime from installation
- `--only [apm|mcp]` - Install only specific dependency type
- `--update` - Update dependencies to latest Git references
Expand All @@ -124,6 +127,12 @@ apm install [OPTIONS]
# Install all dependencies from apm.yml
apm install

# Auto-create apm.yml and install package (no init needed!)
apm install danielmeppiel/design-guidelines

# Add multiple packages and install
apm install org/pkg1 org/pkg2

# Install only APM dependencies (skip MCP servers)
apm install --only=apm

Expand All @@ -140,12 +149,15 @@ apm install --update
apm install --exclude codex
```

**Auto-Bootstrap Behavior:**
- **With packages + no apm.yml**: Automatically creates minimal `apm.yml`, adds packages, and installs
- **Without packages + no apm.yml**: Shows helpful error suggesting `apm init` or `apm install <org/repo>`
- **With apm.yml**: Works as before - installs existing dependencies or adds new packages

**Dependency Types:**
- **APM Dependencies**: GitHub repositories containing `.apm/` context collections
- **MCP Dependencies**: Model Context Protocol servers for runtime integration

**Requirements:** Must be run in a directory with `apm.yml` file.

**Working Example with Dependencies:**
```yaml
# Example apm.yml with APM dependencies
Expand Down
19 changes: 16 additions & 3 deletions src/apm_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,7 @@ def _validate_package_exists(package):
return False


@cli.command(help="Install APM and MCP dependencies from apm.yml")
@cli.command(help="📦 Install APM and MCP dependencies (auto-creates apm.yml when installing packages)")
@click.argument("packages", nargs=-1)
@click.option("--runtime", help="Target specific runtime only (copilot, codex, vscode)")
@click.option("--exclude", help="Exclude specific runtime from installation")
Expand Down Expand Up @@ -455,8 +455,21 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run):
"""
try:
# Check if apm.yml exists
if not Path("apm.yml").exists():
_rich_error("No apm.yml found. Run 'apm init' first.")
apm_yml_exists = Path("apm.yml").exists()

# Auto-bootstrap: create minimal apm.yml when packages specified but no apm.yml
if not apm_yml_exists and packages:
# Get current directory name as project name
project_name = Path.cwd().name
config = _get_default_config(project_name)
_create_minimal_apm_yml(config)
_rich_success("Created apm.yml", symbol="sparkles")

# Error when NO apm.yml AND NO packages
if not apm_yml_exists and not packages:
_rich_error("No apm.yml found")
_rich_info("💡 Run 'apm init' to create one, or:")
_rich_info(" apm install <org/repo> to auto-create + install")
sys.exit(1)

# If packages are specified, validate and add them to apm.yml first
Expand Down
239 changes: 239 additions & 0 deletions tests/unit/test_install_command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
"""Tests for the apm install command auto-bootstrap feature."""

import pytest
import tempfile
import os
import yaml
from pathlib import Path
from click.testing import CliRunner
from unittest.mock import patch, MagicMock

from apm_cli.cli import cli


class TestInstallCommandAutoBootstrap:
"""Test cases for apm install command auto-bootstrap feature."""

def setup_method(self):
"""Set up test fixtures."""
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):
"""Clean up after tests."""
try:
os.chdir(self.original_dir)
except (FileNotFoundError, OSError):
repo_root = Path(__file__).parent.parent.parent
os.chdir(str(repo_root))

def test_install_no_apm_yml_no_packages_shows_helpful_error(self):
"""Test that install without apm.yml and without packages shows helpful error."""
with tempfile.TemporaryDirectory() as tmp_dir:
os.chdir(tmp_dir)

result = self.runner.invoke(cli, ["install"])

assert result.exit_code == 1
assert "No apm.yml found" in result.output
assert "apm init" in result.output
assert "apm install <org/repo>" in result.output

@patch("apm_cli.cli._validate_package_exists")
@patch("apm_cli.cli.APM_DEPS_AVAILABLE", True)
@patch("apm_cli.cli.APMPackage")
@patch("apm_cli.cli._install_apm_dependencies")
def test_install_no_apm_yml_with_packages_creates_minimal_apm_yml(
self, mock_install_apm, mock_apm_package, mock_validate, monkeypatch
):
"""Test that install with packages but no apm.yml creates minimal apm.yml."""
with tempfile.TemporaryDirectory() as tmp_dir:
os.chdir(tmp_dir)

# Mock package validation to return True
mock_validate.return_value = True

# Mock APMPackage to return empty dependencies
mock_pkg_instance = MagicMock()
mock_pkg_instance.get_apm_dependencies.return_value = [
MagicMock(repo_url="test/package", reference="main")
]
mock_pkg_instance.get_mcp_dependencies.return_value = []
mock_apm_package.from_apm_yml.return_value = mock_pkg_instance

# Mock the install function to avoid actual installation
mock_install_apm.return_value = None

result = self.runner.invoke(cli, ["install", "test/package"])

# Should succeed and create apm.yml
assert result.exit_code == 0
assert "Created apm.yml" in result.output
assert Path("apm.yml").exists()

# Verify apm.yml structure
with open("apm.yml") as f:
config = yaml.safe_load(f)
assert "dependencies" in config
assert "apm" in config["dependencies"]
assert "test/package" in config["dependencies"]["apm"]
assert config["dependencies"]["mcp"] == []

@patch("apm_cli.cli._validate_package_exists")
@patch("apm_cli.cli.APM_DEPS_AVAILABLE", True)
@patch("apm_cli.cli.APMPackage")
@patch("apm_cli.cli._install_apm_dependencies")
def test_install_no_apm_yml_with_multiple_packages(
self, mock_install_apm, mock_apm_package, mock_validate, monkeypatch
):
"""Test that install with multiple packages creates apm.yml and adds all."""
with tempfile.TemporaryDirectory() as tmp_dir:
os.chdir(tmp_dir)

# Mock package validation
mock_validate.return_value = True

# Mock APMPackage
mock_pkg_instance = MagicMock()
mock_pkg_instance.get_apm_dependencies.return_value = [
MagicMock(repo_url="org1/pkg1", reference="main"),
MagicMock(repo_url="org2/pkg2", reference="main"),
]
mock_pkg_instance.get_mcp_dependencies.return_value = []
mock_apm_package.from_apm_yml.return_value = mock_pkg_instance

mock_install_apm.return_value = None

result = self.runner.invoke(cli, ["install", "org1/pkg1", "org2/pkg2"])

assert result.exit_code == 0
assert "Created apm.yml" in result.output
assert Path("apm.yml").exists()

# Verify both packages are in apm.yml
with open("apm.yml") as f:
config = yaml.safe_load(f)
assert "org1/pkg1" in config["dependencies"]["apm"]
assert "org2/pkg2" in config["dependencies"]["apm"]

@patch("apm_cli.cli.APM_DEPS_AVAILABLE", True)
@patch("apm_cli.cli.APMPackage")
@patch("apm_cli.cli._install_apm_dependencies")
def test_install_existing_apm_yml_preserves_behavior(
self, mock_install_apm, mock_apm_package
):
"""Test that install with existing apm.yml works as before."""
with tempfile.TemporaryDirectory() as tmp_dir:
os.chdir(tmp_dir)

# Create existing apm.yml
existing_config = {
"name": "test-project",
"version": "1.0.0",
"description": "Test project",
"author": "Test Author",
"dependencies": {"apm": [], "mcp": []},
"scripts": {},
}
with open("apm.yml", "w") as f:
yaml.dump(existing_config, f)

# Mock APMPackage
mock_pkg_instance = MagicMock()
mock_pkg_instance.get_apm_dependencies.return_value = []
mock_pkg_instance.get_mcp_dependencies.return_value = []
mock_apm_package.from_apm_yml.return_value = mock_pkg_instance

result = self.runner.invoke(cli, ["install"])

# Should succeed and NOT show "Created apm.yml"
assert result.exit_code == 0
assert "Created apm.yml" not in result.output

# Verify original config is preserved
with open("apm.yml") as f:
config = yaml.safe_load(f)
assert config["name"] == "test-project"
assert config["author"] == "Test Author"

@patch("apm_cli.cli._validate_package_exists")
@patch("apm_cli.cli.APM_DEPS_AVAILABLE", True)
@patch("apm_cli.cli.APMPackage")
@patch("apm_cli.cli._install_apm_dependencies")
def test_install_auto_created_apm_yml_has_correct_metadata(
self, mock_install_apm, mock_apm_package, mock_validate
):
"""Test that auto-created apm.yml has correct metadata."""
with tempfile.TemporaryDirectory() as tmp_dir:
# Create a directory with a specific name to test project name detection
project_dir = Path(tmp_dir) / "my-awesome-project"
project_dir.mkdir()
os.chdir(project_dir)

# Mock validation and installation
mock_validate.return_value = True

mock_pkg_instance = MagicMock()
mock_pkg_instance.get_apm_dependencies.return_value = [
MagicMock(repo_url="test/package", reference="main")
]
mock_pkg_instance.get_mcp_dependencies.return_value = []
mock_apm_package.from_apm_yml.return_value = mock_pkg_instance

mock_install_apm.return_value = None

result = self.runner.invoke(cli, ["install", "test/package"])

assert result.exit_code == 0
assert Path("apm.yml").exists()

# Verify auto-detected project name
with open("apm.yml") as f:
config = yaml.safe_load(f)
assert config["name"] == "my-awesome-project"
assert "version" in config
assert "description" in config
assert "APM project" in config["description"]

@patch("apm_cli.cli._validate_package_exists")
def test_install_invalid_package_format_with_no_apm_yml(self, mock_validate):
"""Test that invalid package format fails gracefully even with auto-bootstrap."""
with tempfile.TemporaryDirectory() as tmp_dir:
os.chdir(tmp_dir)

# Don't mock validation - let it handle invalid format
result = self.runner.invoke(cli, ["install", "invalid-package"])

# Should create apm.yml but fail to add invalid package
assert Path("apm.yml").exists()
assert "Invalid package format" in result.output

@patch("apm_cli.cli._validate_package_exists")
@patch("apm_cli.cli.APM_DEPS_AVAILABLE", True)
@patch("apm_cli.cli.APMPackage")
@patch("apm_cli.cli._install_apm_dependencies")
def test_install_dry_run_with_no_apm_yml_shows_what_would_be_created(
self, mock_install_apm, mock_apm_package, mock_validate
):
"""Test that dry-run with no apm.yml shows what would be created."""
with tempfile.TemporaryDirectory() as tmp_dir:
os.chdir(tmp_dir)

mock_validate.return_value = True

mock_pkg_instance = MagicMock()
mock_pkg_instance.get_apm_dependencies.return_value = []
mock_pkg_instance.get_mcp_dependencies.return_value = []
mock_apm_package.from_apm_yml.return_value = mock_pkg_instance

result = self.runner.invoke(cli, ["install", "test/package", "--dry-run"])

# Should show what would be added
assert result.exit_code == 0
assert "Would add" in result.output or "Dry run" in result.output
# apm.yml should still be created (for dry-run to work)
assert Path("apm.yml").exists()