diff --git a/.gitignore b/.gitignore
index 85078d77c..d457ff8d4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -37,6 +37,7 @@ ENV/
 
 # IDEs and editors
 .idea/
+*.code-workspace
 *.swp
 *.swo
 *~
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ab8347740..85924259b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [Unreleased]
 
+### Fixed
+
+- Align `apm compile` and `apm install` CLI reference/help text with current target support and verbose diagnostics wording (#365)
+- CLI docs now document the `apm compile --target opencode` option, align `apm audit --dry-run` wording with actual behavior, and mark planned `apm audit --drift` examples as not yet available (#366)
+
 ## [0.8.2] - 2026-03-19
 
 ### Added
@@ -476,4 +481,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - Context Dependencies
 - Context Compilation
 - GitHub MCP Registry integration
-- Codex CLI Support
\ No newline at end of file
+- Codex CLI Support
diff --git a/docs/src/content/docs/enterprise/governance.md b/docs/src/content/docs/enterprise/governance.md
index d1f1090ea..70e035ce4 100644
--- a/docs/src/content/docs/enterprise/governance.md
+++ b/docs/src/content/docs/enterprise/governance.md
@@ -206,22 +206,21 @@ Drift occurs when the on-disk state of agent configuration diverges from what th
 ### Usage
 
 ```bash
-# Check for drift (human-readable output)
-apm audit --drift
-
-# Check for drift in CI (non-zero exit code on detection)
-apm audit --drift --ci
-
-# Use as a pre-commit check
-# .pre-commit-config.yaml
-repos:
-  - repo: local
-    hooks:
-      - id: apm-drift
-        name: APM drift check
-        entry: apm audit --drift
-        language: system
-        pass_filenames: false
+# Planned command syntax once drift detection ships:
+# apm audit --drift
+
+# Planned CI syntax once drift detection ships:
+# apm audit --drift --ci
+
+# Planned .pre-commit-config.yaml entry (NOT YET AVAILABLE)
+# repos:
+#   - repo: local
+#     hooks:
+#       - id: apm-drift
+#         name: APM drift check
+#         entry: apm audit --drift
+#         language: system
+#         pass_filenames: false
 ```
 
 Drift detection complements lock file verification. The audit checks that the lock file matches the manifest; drift detection checks that the file system matches the lock file.
diff --git a/docs/src/content/docs/reference/cli-commands.md b/docs/src/content/docs/reference/cli-commands.md
index 9ee13cfb2..89cb0989e 100644
--- a/docs/src/content/docs/reference/cli-commands.md
+++ b/docs/src/content/docs/reference/cli-commands.md
@@ -344,7 +344,7 @@ apm audit [PACKAGE] [OPTIONS]
 **Options:**
 - `--file PATH` - Scan an arbitrary file instead of installed packages
 - `--strip` - Remove dangerous characters (critical + warning severity) while preserving info-level content like emoji. ZWJ inside emoji sequences is preserved.
-- `--dry-run` - Preview what `--strip` would remove without modifying files (requires `--strip`)
+- `--dry-run` - Preview what `--strip` would remove without modifying files
 - `-v, --verbose` - Show info-level findings and file details
 - `-f, --format [text|json|sarif|markdown]` - Output format: `text` (default), `json` (machine-readable), `sarif` (GitHub Code Scanning), `markdown` (step summaries). Cannot be combined with `--strip` or `--dry-run`.
 - `-o, --output PATH` - Write report to file. Auto-detects format from extension (`.sarif`, `.sarif.json` → SARIF; `.json` → JSON; `.md` → Markdown) when `--format` is not specified.
@@ -854,7 +854,7 @@ apm compile [OPTIONS]
 
 **Options:**
 - `-o, --output TEXT` - Output file path (for single-file mode)
-- `-t, --target [vscode|agents|claude|all]` - Target agent format. `agents` is an alias for `vscode`. Auto-detects if not specified.
+- `-t, --target [vscode|agents|claude|opencode|all]` - Target platform: vscode/agents (AGENTS.md), claude (CLAUDE.md), opencode (AGENTS.md), or all. Auto-detects if not specified.
 - `--chatmode TEXT` - Chatmode to prepend to the AGENTS.md file
 - `--dry-run` - Preview compilation without writing files (shows placement decisions)
 - `--no-links` - Skip markdown link resolution
@@ -874,6 +874,7 @@ When `--target` is not specified, APM auto-detects based on existing project str
 |-----------|--------|--------|
 | `.github/` exists only | `vscode` | AGENTS.md + .github/ |
 | `.claude/` exists only | `claude` | CLAUDE.md + .claude/ |
+| `.opencode/` exists only | `opencode` | AGENTS.md + .opencode/ |
 | Both folders exist | `all` | All outputs |
 | Neither folder exists | `minimal` | AGENTS.md only |
 
@@ -881,7 +882,7 @@ You can also set a persistent target in `apm.yml`:
 ```yaml
 name: my-project
 version: 1.0.0
-target: vscode  # or claude, or all
+target: vscode  # or claude, opencode, or all
 ```
 
 **Target Formats (explicit):**
@@ -889,9 +890,13 @@ target: vscode  # or claude, or all
 | Target | Output Files | Best For |
 |--------|--------------|----------|
 | `vscode` | AGENTS.md, .github/prompts/, .github/agents/, .github/skills/ | GitHub Copilot, Cursor, Codex, Gemini |
+| `agents` | Same as `vscode` (alias target) | Alternative alias for `vscode` |
 | `claude` | CLAUDE.md, .claude/commands/, SKILL.md | Claude Code, Claude Desktop |
+| `opencode` | AGENTS.md, .opencode/agents/, .opencode/commands/, .opencode/skills/ | OpenCode |
 | `all` | All of the above | Universal compatibility |
 
+Cursor rules are deployed by `apm install`, not `apm compile`.
+
 **Examples:**
 ```bash
 # Basic compilation with auto-detected context
@@ -918,6 +923,7 @@ apm compile --watch --dry-run
 # Target specific agent formats
 apm compile --target vscode    # AGENTS.md + .github/ only
 apm compile --target claude    # CLAUDE.md + .claude/ only
+apm compile --target opencode  # AGENTS.md + .opencode/ only
 apm compile --target all       # All formats (default)
 
 # Compile injecting Spec Kit constitution (auto-detected)
@@ -1185,4 +1191,3 @@ apm runtime status
 - Runtime preference order (copilot → codex → llm)
 - Currently active runtime
 - Next steps if no runtime is available
-
diff --git a/src/apm_cli/commands/_helpers.py b/src/apm_cli/commands/_helpers.py
index f9d8a8d2f..54fd26a63 100644
--- a/src/apm_cli/commands/_helpers.py
+++ b/src/apm_cli/commands/_helpers.py
@@ -12,6 +12,14 @@
 from colorama import Fore, Style
 from colorama import init as colorama_init
 
+from ..constants import (
+    APM_DIR,
+    APM_LOCK_FILENAME,
+    APM_MODULES_DIR,
+    APM_MODULES_GITIGNORE_PATTERN,
+    APM_YML_FILENAME,
+    GITIGNORE_FILENAME,
+)
 from ..utils.console import _rich_echo, _rich_info, _rich_warning
 from ..version import get_build_sha, get_version
 from ..utils.version_checker import check_for_updates
@@ -157,7 +165,7 @@ def _scan_installed_packages(apm_modules_dir: Path) -> list:
     for candidate in apm_modules_dir.rglob("*"):
         if not candidate.is_dir() or candidate.name.startswith("."):
             continue
-        if not ((candidate / "apm.yml").exists() or (candidate / ".apm").exists()):
+        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:
@@ -176,10 +184,10 @@ def _check_orphaned_packages():
         List[str]: List of orphaned package names in org/repo or org/project/repo format
     """
     try:
-        if not Path("apm.yml").exists():
+        if not Path(APM_YML_FILENAME).exists():
             return []
 
-        apm_modules_dir = Path("apm_modules")
+        apm_modules_dir = Path(APM_MODULES_DIR)
         if not apm_modules_dir.exists():
             return []
 
@@ -187,7 +195,7 @@ def _check_orphaned_packages():
             from ..models.apm_package import APMPackage
             from ..deps.lockfile import LockFile, get_lockfile_path
 
-            apm_package = APMPackage.from_apm_yml(Path("apm.yml"))
+            apm_package = APMPackage.from_apm_yml(Path(APM_YML_FILENAME))
             declared_deps = apm_package.get_apm_dependencies()
             lockfile = LockFile.read(get_lockfile_path(Path.cwd()))
             expected = _build_expected_install_paths(declared_deps, lockfile, apm_modules_dir)
@@ -277,8 +285,8 @@ def _atomic_write(path: Path, data: str) -> None:
 
 def _update_gitignore_for_apm_modules():
     """Add apm_modules/ to .gitignore if not already present."""
-    gitignore_path = Path(".gitignore")
-    apm_modules_pattern = "apm_modules/"
+    gitignore_path = Path(GITIGNORE_FILENAME)
+    apm_modules_pattern = APM_MODULES_GITIGNORE_PATTERN
 
     # Read current .gitignore content
     current_content = []
@@ -313,8 +321,8 @@ def _update_gitignore_for_apm_modules():
 
 def _load_apm_config():
     """Load configuration from apm.yml."""
-    if Path("apm.yml").exists():
-        with open("apm.yml", "r") as f:
+    if Path(APM_YML_FILENAME).exists():
+        with open(APM_YML_FILENAME, "r") as f:
             yaml = _lazy_yaml()
             return yaml.safe_load(f)
     return None
@@ -401,5 +409,5 @@ def _create_minimal_apm_yml(config):
     }
 
     # Write apm.yml
-    with open("apm.yml", "w") as f:
+    with open(APM_YML_FILENAME, "w") as f:
         yaml.safe_dump(apm_yml_data, f, default_flow_style=False, sort_keys=False)
diff --git a/src/apm_cli/commands/compile/__init__.py b/src/apm_cli/commands/compile/__init__.py
new file mode 100644
index 000000000..2e92dc72e
--- /dev/null
+++ b/src/apm_cli/commands/compile/__init__.py
@@ -0,0 +1,11 @@
+"""APM compile command."""
+
+from .cli import compile, _display_validation_errors, _get_validation_suggestion
+from .watcher import _watch_mode
+
+__all__ = [
+    "compile",
+    "_display_validation_errors",
+    "_get_validation_suggestion",
+    "_watch_mode",
+]
diff --git a/src/apm_cli/commands/compile.py b/src/apm_cli/commands/compile/cli.py
similarity index 60%
rename from src/apm_cli/commands/compile.py
rename to src/apm_cli/commands/compile/cli.py
index 3eacf8ec0..2dd61d9bc 100644
--- a/src/apm_cli/commands/compile.py
+++ b/src/apm_cli/commands/compile/cli.py
@@ -1,14 +1,14 @@
-"""APM compile command."""
+"""APM compile command CLI."""
 
 import sys
 from pathlib import Path
 
 import click
 
-from ..compilation import AgentsCompiler, CompilationConfig
-from ..primitives.discovery import discover_primitives
-
-from ..utils.console import (
+from ...constants import AGENTS_MD_FILENAME, APM_DIR, APM_MODULES_DIR, APM_YML_FILENAME
+from ...compilation import AgentsCompiler, CompilationConfig
+from ...primitives.discovery import discover_primitives
+from ...utils.console import (
     STATUS_SYMBOLS,
     _rich_echo,
     _rich_error,
@@ -17,12 +17,97 @@
     _rich_success,
     _rich_warning,
 )
-from ._helpers import (
+from .._helpers import (
     _atomic_write,
     _check_orphaned_packages,
     _get_console,
     _rich_blank_line,
 )
+from .watcher import _watch_mode
+
+
+def _display_single_file_summary(stats, c_status, c_hash, output_path, dry_run):
+    """Display compilation summary table for single-file mode."""
+    try:
+        console = _get_console()
+        if not console:
+            _rich_info(f"Processed {stats.get('primitives_found', 0)} primitives:")
+            _rich_info(f"  * {stats.get('instructions', 0)} instructions")
+            _rich_info(f"  * {stats.get('contexts', 0)} contexts")
+            _rich_info(f"Constitution status: {c_status} hash={c_hash or '-'}")
+            return
+
+        import os
+        from rich.table import Table
+
+        table = Table(
+            title="Compilation Summary",
+            show_header=True,
+            header_style="bold cyan",
+        )
+        table.add_column("Component", style="bold white", min_width=15)
+        table.add_column("Count", style="cyan", min_width=8)
+        table.add_column("Details", style="white", min_width=20)
+
+        constitution_details = f"Hash: {c_hash or '-'}"
+        table.add_row("Spec-kit Constitution", c_status, constitution_details)
+
+        table.add_row(
+            "Instructions",
+            str(stats.get("instructions", 0)),
+            "[+] All validated",
+        )
+        table.add_row(
+            "Contexts",
+            str(stats.get("contexts", 0)),
+            "[+] All validated",
+        )
+        table.add_row(
+            "Chatmodes",
+            str(stats.get("chatmodes", 0)),
+            "[+] All validated",
+        )
+
+        try:
+            file_size = os.path.getsize(output_path) if not dry_run else 0
+            size_str = f"{file_size/1024:.1f}KB" if file_size > 0 else "Preview"
+            output_details = f"{output_path.name} ({size_str})"
+        except Exception:
+            output_details = f"{output_path.name}"
+
+        table.add_row("Output", "* SUCCESS", output_details)
+        console.print(table)
+    except Exception:
+        _rich_info(f"Processed {stats.get('primitives_found', 0)} primitives:")
+        _rich_info(f"  * {stats.get('instructions', 0)} instructions")
+        _rich_info(f"  * {stats.get('contexts', 0)} contexts")
+        _rich_info(f"Constitution status: {c_status} hash={c_hash or '-'}")
+
+
+def _display_next_steps(output):
+    """Display next steps panel after successful single-file compilation."""
+    next_steps = [
+        f"Review the generated {output} file",
+        "Install MCP dependencies: apm install",
+        "Execute agentic workflows: apm run <script> --param key=value",
+    ]
+    try:
+        console = _get_console()
+        if console:
+            from rich.panel import Panel
+
+            steps_content = "\n".join(f"* {step}" for step in next_steps)
+            console.print(
+                Panel(steps_content, title=" Next Steps", border_style="blue")
+            )
+        else:
+            _rich_info("Next steps:")
+            for step in next_steps:
+                click.echo(f"  * {step}")
+    except (ImportError, NameError):
+        _rich_info("Next steps:")
+        for step in next_steps:
+            click.echo(f"  * {step}")
 
 
 def _display_validation_errors(errors):
@@ -80,172 +165,11 @@ def _get_validation_suggestion(error_msg):
         return "Check primitive structure and frontmatter"
 
 
-def _watch_mode(output, chatmode, no_links, dry_run):
-    """Watch for changes in .apm/ directories and auto-recompile."""
-    try:
-        # Try to import watchdog for file system monitoring
-        import time
-
-        from watchdog.events import FileSystemEventHandler
-        from watchdog.observers import Observer
-
-        class APMFileHandler(FileSystemEventHandler):
-            def __init__(self, output, chatmode, no_links, dry_run):
-                self.output = output
-                self.chatmode = chatmode
-                self.no_links = no_links
-                self.dry_run = dry_run
-                self.last_compile = 0
-                self.debounce_delay = 1.0  # 1 second debounce
-
-            def on_modified(self, event):
-                if event.is_directory:
-                    return
-
-                # Check if it's a relevant file
-                if event.src_path.endswith(".md") or event.src_path.endswith("apm.yml"):
-
-                    # Debounce rapid changes
-                    current_time = time.time()
-                    if current_time - self.last_compile < self.debounce_delay:
-                        return
-
-                    self.last_compile = current_time
-                    self._recompile(event.src_path)
-
-            def _recompile(self, changed_file):
-                """Recompile after file change."""
-                try:
-                    _rich_info(f"File changed: {changed_file}", symbol="eyes")
-                    _rich_info("Recompiling...", symbol="gear")
-
-                    # Create configuration from apm.yml with overrides
-                    config = CompilationConfig.from_apm_yml(
-                        output_path=self.output if self.output != "AGENTS.md" else None,
-                        chatmode=self.chatmode,
-                        resolve_links=not self.no_links if self.no_links else None,
-                        dry_run=self.dry_run,
-                    )
-
-                    # Create compiler and compile
-                    compiler = AgentsCompiler(".")
-                    result = compiler.compile(config)
-
-                    if result.success:
-                        if self.dry_run:
-                            _rich_success(
-                                "Recompilation successful (dry run)", symbol="sparkles"
-                            )
-                        else:
-                            _rich_success(
-                                f"Recompiled to {result.output_path}", symbol="sparkles"
-                            )
-                    else:
-                        _rich_error("Recompilation failed")
-                        for error in result.errors:
-                            click.echo(f"  [x] {error}")
-
-                except Exception as e:
-                    _rich_error(f"Error during recompilation: {e}")
-
-        # Set up file watching
-        event_handler = APMFileHandler(output, chatmode, no_links, dry_run)
-        observer = Observer()
-
-        # Watch patterns for APM files
-        watch_paths = []
-
-        # Check for .apm directory
-        if Path(".apm").exists():
-            observer.schedule(event_handler, ".apm", recursive=True)
-            watch_paths.append(".apm/")
-
-        # Check for .github/instructions and agents/chatmodes
-        if Path(".github/instructions").exists():
-            observer.schedule(event_handler, ".github/instructions", recursive=True)
-            watch_paths.append(".github/instructions/")
-
-        # Watch .github/agents/ (new standard)
-        if Path(".github/agents").exists():
-            observer.schedule(event_handler, ".github/agents", recursive=True)
-            watch_paths.append(".github/agents/")
-
-        # Watch .github/chatmodes/ (legacy)
-        if Path(".github/chatmodes").exists():
-            observer.schedule(event_handler, ".github/chatmodes", recursive=True)
-            watch_paths.append(".github/chatmodes/")
-
-        # Watch apm.yml if it exists
-        if Path("apm.yml").exists():
-            observer.schedule(event_handler, ".", recursive=False)
-            watch_paths.append("apm.yml")
-
-        if not watch_paths:
-            _rich_warning("No APM directories found to watch")
-            _rich_info("Run 'apm init' to create an APM project")
-            return
-
-        # Start watching
-        observer.start()
-        _rich_info(
-            f" Watching for changes in: {', '.join(watch_paths)}", symbol="eyes"
-        )
-        _rich_info("Press Ctrl+C to stop watching...", symbol="info")
-
-        # Do initial compilation
-        _rich_info("Performing initial compilation...", symbol="gear")
-
-        config = CompilationConfig.from_apm_yml(
-            output_path=output if output != "AGENTS.md" else None,
-            chatmode=chatmode,
-            resolve_links=not no_links if no_links else None,
-            dry_run=dry_run,
-        )
-
-        compiler = AgentsCompiler(".")
-        result = compiler.compile(config)
-
-        if result.success:
-            if dry_run:
-                _rich_success(
-                    "Initial compilation successful (dry run)", symbol="sparkles"
-                )
-            else:
-                _rich_success(
-                    f"Initial compilation complete: {result.output_path}",
-                    symbol="sparkles",
-                )
-        else:
-            _rich_error("Initial compilation failed")
-            for error in result.errors:
-                click.echo(f"  [x] {error}")
-
-        try:
-            while True:
-                time.sleep(1)
-        except KeyboardInterrupt:
-            observer.stop()
-            _rich_info("Stopped watching for changes", symbol="info")
-
-        observer.join()
-
-    except ImportError:
-        _rich_error("Watch mode requires the 'watchdog' library")
-        _rich_info("Install it with: uv pip install watchdog")
-        _rich_info(
-            "Or reinstall APM: uv pip install -e . (from the apm directory)"
-        )
-        sys.exit(1)
-    except Exception as e:
-        _rich_error(f"Error in watch mode: {e}")
-        sys.exit(1)
-
-
 @click.command(help="Compile APM context into distributed AGENTS.md files")
 @click.option(
     "--output",
     "-o",
-    default="AGENTS.md",
+    default=AGENTS_MD_FILENAME,
     help="Output file path (for single-file mode)",
 )
 @click.option(
@@ -330,20 +254,20 @@ def compile(
         # Check if this is an APM project first
         from pathlib import Path
 
-        if not Path("apm.yml").exists():
+        if not Path(APM_YML_FILENAME).exists():
             _rich_error("[x] Not an APM project - no apm.yml found")
             _rich_info(" To initialize an APM project, run:")
             _rich_info("   apm init")
             sys.exit(1)
 
         # Check if there are any instruction files to compile
-        from ..compilation.constitution import find_constitution
+        from ...compilation.constitution import find_constitution
 
-        apm_modules_exists = Path("apm_modules").exists()
+        apm_modules_exists = Path(APM_MODULES_DIR).exists()
         constitution_exists = find_constitution(Path(".")).exists()
 
         # Check if .apm directory has actual content
-        apm_dir = Path(".apm")
+        apm_dir = Path(APM_DIR)
         local_apm_has_content = apm_dir.exists() and (
             any(apm_dir.rglob("*.instructions.md"))
             or any(apm_dir.rglob("*.chatmode.md"))
@@ -401,8 +325,8 @@ def compile(
             _rich_info(f"  * {len(primitives.contexts)} contexts")
             # Show MCP dependency validation count
             try:
-                from ..models.apm_package import APMPackage
-                apm_pkg = APMPackage.from_apm_yml(Path("apm.yml"))
+                from ...models.apm_package import APMPackage
+                apm_pkg = APMPackage.from_apm_yml(Path(APM_YML_FILENAME))
                 mcp_count = len(apm_pkg.get_mcp_dependencies())
                 if mcp_count > 0:
                     _rich_info(f"  * {mcp_count} MCP dependencies")
@@ -418,14 +342,14 @@ def compile(
         _rich_info("Starting context compilation...", symbol="cogs")
 
         # Auto-detect target if not explicitly provided
-        from ..core.target_detection import detect_target, get_target_description
+        from ...core.target_detection import detect_target, get_target_description
 
         # Get config target from apm.yml if available
         config_target = None
         try:
-            from ..models.apm_package import APMPackage
+            from ...models.apm_package import APMPackage
 
-            apm_pkg = APMPackage.from_apm_yml(Path("apm.yml"))
+            apm_pkg = APMPackage.from_apm_yml(Path(APM_YML_FILENAME))
             config_target = apm_pkg.target
         except Exception:
             # No apm.yml or parsing error - proceed with auto-detection
@@ -442,7 +366,7 @@ def compile(
 
         # Build config with distributed compilation flags (Task 7)
         config = CompilationConfig.from_apm_yml(
-            output_path=output if output != "AGENTS.md" else None,
+            output_path=output if output != AGENTS_MD_FILENAME else None,
             chatmode=chatmode,
             resolve_links=not no_links if no_links else None,
             dry_run=dry_run,
@@ -520,7 +444,7 @@ def compile(
 
                 if intermediate_result.success:
                     # Perform constitution injection / preservation
-                    from ..compilation.injector import ConstitutionInjector
+                    from ...compilation.injector import ConstitutionInjector
 
                     injector = ConstitutionInjector(base_dir=".")
                     output_path = Path(config.output_path)
@@ -533,7 +457,7 @@ def compile(
                     # Compute deterministic Build ID (12-char SHA256) over content with placeholder removed
                     import hashlib
 
-                    from ..compilation.constants import BUILD_ID_PLACEHOLDER
+                    from ...compilation.constants import BUILD_ID_PLACEHOLDER
 
                     lines = final_content.splitlines()
                     # Identify placeholder line index
@@ -554,7 +478,7 @@ def compile(
                         # Only rewrite when content materially changes (creation, update, missing constitution case)
                         if c_status in ("CREATED", "UPDATED", "MISSING"):
                             # Defense-in-depth: scan compiled output before writing
-                            from ..security.gate import WARN_POLICY, SecurityGate
+                            from ...security.gate import WARN_POLICY, SecurityGate
 
                             verdict = SecurityGate.scan_text(
                                 final_content, str(output_path), policy=WARN_POLICY
@@ -597,87 +521,7 @@ def compile(
                     # Add spacing before summary table
                     _rich_blank_line()
 
-                    # Single comprehensive compilation summary table
-                    try:
-                        console = _get_console()
-                        if console:
-                            import os
-
-                            from rich.table import Table
-
-                            table = Table(
-                                title="Compilation Summary",
-                                show_header=True,
-                                header_style="bold cyan",
-                            )
-                            table.add_column(
-                                "Component", style="bold white", min_width=15
-                            )
-                            table.add_column("Count", style="cyan", min_width=8)
-                            table.add_column("Details", style="white", min_width=20)
-
-                            # Constitution row
-                            constitution_details = f"Hash: {c_hash or '-'}"
-                            table.add_row(
-                                "Spec-kit Constitution", c_status, constitution_details
-                            )
-
-                            # Primitives rows
-                            table.add_row(
-                                "Instructions",
-                                str(stats.get("instructions", 0)),
-                                "[+] All validated",
-                            )
-                            table.add_row(
-                                "Contexts",
-                                str(stats.get("contexts", 0)),
-                                "[+] All validated",
-                            )
-                            table.add_row(
-                                "Chatmodes",
-                                str(stats.get("chatmodes", 0)),
-                                "[+] All validated",
-                            )
-
-                            # Output row with file size
-                            try:
-                                file_size = (
-                                    os.path.getsize(output_path) if not dry_run else 0
-                                )
-                                size_str = (
-                                    f"{file_size/1024:.1f}KB"
-                                    if file_size > 0
-                                    else "Preview"
-                                )
-                                output_details = f"{output_path.name} ({size_str})"
-                            except:
-                                output_details = f"{output_path.name}"
-
-                            table.add_row("Output", "* SUCCESS", output_details)
-
-                            console.print(table)
-                        else:
-                            # Fallback for no Rich console
-                            _rich_info(
-                                f"Processed {stats.get('primitives_found', 0)} primitives:"
-                            )
-                            _rich_info(
-                                f"  * {stats.get('instructions', 0)} instructions"
-                            )
-                            _rich_info(f"  * {stats.get('contexts', 0)} contexts")
-                            _rich_info(
-                                f"Constitution status: {c_status} hash={c_hash or '-'}"
-                            )
-                    except Exception:
-                        # Fallback for any errors
-                        _rich_info(
-                            f"Processed {stats.get('primitives_found', 0)} primitives:"
-                        )
-                        _rich_info(f"  * {stats.get('instructions', 0)} instructions")
-                        _rich_info(f"  * {stats.get('contexts', 0)} contexts")
-                        _rich_info(
-                            f"Constitution status: {c_status} hash={c_hash or '-'}"
-                        )
+                    _display_single_file_summary(stats, c_status, c_hash, output_path, dry_run)
 
                     if dry_run:
                         preview = final_content[:500] + (
@@ -687,34 +531,7 @@ def compile(
                             preview, title=" Generated Content Preview", style="cyan"
                         )
                     else:
-                        next_steps = [
-                            f"Review the generated {output} file",
-                            "Install MCP dependencies: apm install",
-                            "Execute agentic workflows: apm run <script> --param key=value",
-                        ]
-                        try:
-                            console = _get_console()
-                            if console:
-                                from rich.panel import Panel
-
-                                steps_content = "\n".join(
-                                    f"* {step}" for step in next_steps
-                                )
-                                console.print(
-                                    Panel(
-                                        steps_content,
-                                        title=" Next Steps",
-                                        border_style="blue",
-                                    )
-                                )
-                            else:
-                                _rich_info("Next steps:")
-                                for step in next_steps:
-                                    click.echo(f"  * {step}")
-                        except (ImportError, NameError):
-                            _rich_info("Next steps:")
-                            for step in next_steps:
-                                click.echo(f"  * {step}")
+                        _display_next_steps(output)
 
         # Common error handling for both compilation modes
         # Note: Warnings are handled by professional formatters for distributed mode
diff --git a/src/apm_cli/commands/compile/watcher.py b/src/apm_cli/commands/compile/watcher.py
new file mode 100644
index 000000000..987419ace
--- /dev/null
+++ b/src/apm_cli/commands/compile/watcher.py
@@ -0,0 +1,171 @@
+"""APM compile watch mode."""
+
+import time
+
+import click
+
+from ...constants import AGENTS_MD_FILENAME, APM_DIR, APM_YML_FILENAME
+from ...compilation import AgentsCompiler, CompilationConfig
+from ...utils.console import _rich_error, _rich_info, _rich_success, _rich_warning
+
+
+def _watch_mode(output, chatmode, no_links, dry_run):
+    """Watch for changes in .apm/ directories and auto-recompile."""
+    try:
+        # Try to import watchdog for file system monitoring
+        from pathlib import Path
+
+        from watchdog.events import FileSystemEventHandler
+        from watchdog.observers import Observer
+
+        class APMFileHandler(FileSystemEventHandler):
+            def __init__(self, output, chatmode, no_links, dry_run):
+                self.output = output
+                self.chatmode = chatmode
+                self.no_links = no_links
+                self.dry_run = dry_run
+                self.last_compile = 0
+                self.debounce_delay = 1.0  # 1 second debounce
+
+            def on_modified(self, event):
+                if event.is_directory:
+                    return
+                # Only react to relevant files
+                if not event.src_path.endswith(".md") and not event.src_path.endswith(APM_YML_FILENAME):
+                    return
+                # Debounce rapid changes
+                current_time = time.time()
+                if current_time - self.last_compile < self.debounce_delay:
+                    return
+
+                self.last_compile = current_time
+                self._recompile(event.src_path)
+
+            def _recompile(self, changed_file):
+                """Recompile after file change."""
+                try:
+                    _rich_info(f"File changed: {changed_file}", symbol="eyes")
+                    _rich_info("Recompiling...", symbol="gear")
+
+                    # Create configuration from apm.yml with overrides
+                    config = CompilationConfig.from_apm_yml(
+                        output_path=self.output if self.output != AGENTS_MD_FILENAME else None,
+                        chatmode=self.chatmode,
+                        resolve_links=not self.no_links if self.no_links else None,
+                        dry_run=self.dry_run,
+                    )
+
+                    # Create compiler and compile
+                    compiler = AgentsCompiler(".")
+                    result = compiler.compile(config)
+
+                    if result.success:
+                        if self.dry_run:
+                            _rich_success(
+                                "Recompilation successful (dry run)", symbol="sparkles"
+                            )
+                        else:
+                            _rich_success(
+                                f"Recompiled to {result.output_path}", symbol="sparkles"
+                            )
+                    else:
+                        _rich_error("Recompilation failed")
+                        for error in result.errors:
+                            click.echo(f"  [x] {error}")
+
+                except Exception as e:
+                    _rich_error(f"Error during recompilation: {e}")
+
+        # Set up file watching
+        event_handler = APMFileHandler(output, chatmode, no_links, dry_run)
+        observer = Observer()
+
+        # Watch patterns for APM files
+        watch_paths = []
+
+        # Check for .apm directory
+        if Path(APM_DIR).exists():
+            observer.schedule(event_handler, APM_DIR, recursive=True)
+            watch_paths.append(f"{APM_DIR}/")
+
+        # Check for .github/instructions and agents/chatmodes
+        if Path(".github/instructions").exists():
+            observer.schedule(event_handler, ".github/instructions", recursive=True)
+            watch_paths.append(".github/instructions/")
+
+        # Watch .github/agents/ (new standard)
+        if Path(".github/agents").exists():
+            observer.schedule(event_handler, ".github/agents", recursive=True)
+            watch_paths.append(".github/agents/")
+
+        # Watch .github/chatmodes/ (legacy)
+        if Path(".github/chatmodes").exists():
+            observer.schedule(event_handler, ".github/chatmodes", recursive=True)
+            watch_paths.append(".github/chatmodes/")
+
+        # Watch apm.yml if it exists
+        if Path(APM_YML_FILENAME).exists():
+            observer.schedule(event_handler, ".", recursive=False)
+            watch_paths.append(APM_YML_FILENAME)
+
+        if not watch_paths:
+            _rich_warning("No APM directories found to watch")
+            _rich_info("Run 'apm init' to create an APM project")
+            return
+
+        # Start watching
+        observer.start()
+        _rich_info(
+            f" Watching for changes in: {', '.join(watch_paths)}", symbol="eyes"
+        )
+        _rich_info("Press Ctrl+C to stop watching...", symbol="info")
+
+        # Do initial compilation
+        _rich_info("Performing initial compilation...", symbol="gear")
+
+        config = CompilationConfig.from_apm_yml(
+            output_path=output if output != AGENTS_MD_FILENAME else None,
+            chatmode=chatmode,
+            resolve_links=not no_links if no_links else None,
+            dry_run=dry_run,
+        )
+
+        compiler = AgentsCompiler(".")
+        result = compiler.compile(config)
+
+        if result.success:
+            if dry_run:
+                _rich_success(
+                    "Initial compilation successful (dry run)", symbol="sparkles"
+                )
+            else:
+                _rich_success(
+                    f"Initial compilation complete: {result.output_path}",
+                    symbol="sparkles",
+                )
+        else:
+            _rich_error("Initial compilation failed")
+            for error in result.errors:
+                click.echo(f"  [x] {error}")
+
+        try:
+            while True:
+                time.sleep(1)
+        except KeyboardInterrupt:
+            observer.stop()
+            _rich_info("Stopped watching for changes", symbol="info")
+
+        observer.join()
+
+    except ImportError:
+        _rich_error("Watch mode requires the 'watchdog' library")
+        _rich_info("Install it with: uv pip install watchdog")
+        _rich_info(
+            "Or reinstall APM: uv pip install -e . (from the apm directory)"
+        )
+        import sys
+        sys.exit(1)
+    except Exception as e:
+        _rich_error(f"Error in watch mode: {e}")
+        import sys
+        sys.exit(1)
diff --git a/src/apm_cli/commands/config.py b/src/apm_cli/commands/config.py
index 90fff9a4b..93429a4b1 100644
--- a/src/apm_cli/commands/config.py
+++ b/src/apm_cli/commands/config.py
@@ -6,6 +6,7 @@
 
 import click
 
+from ..constants import APM_YML_FILENAME
 from ..utils.console import _rich_echo, _rich_error, _rich_info, _rich_success
 from ..version import get_version
 from ._helpers import HIGHLIGHT, RESET, _get_console, _load_apm_config
@@ -36,7 +37,7 @@ def config(ctx):
             config_table.add_column("Value", style="cyan")
 
             # Show apm.yml if in project
-            if Path("apm.yml").exists():
+            if Path(APM_YML_FILENAME).exists():
                 apm_config = _load_apm_config()
                 config_table.add_row(
                     "Project", "Name", apm_config.get("name", "Unknown")
@@ -88,7 +89,7 @@ def config(ctx):
             # Fallback display
             _rich_info("Current APM Configuration:")
 
-            if Path("apm.yml").exists():
+            if Path(APM_YML_FILENAME).exists():
                 apm_config = _load_apm_config()
                 click.echo(f"\n{HIGHLIGHT}Project (apm.yml):{RESET}")
                 click.echo(f"  Name: {apm_config.get('name', 'Unknown')}")
diff --git a/src/apm_cli/commands/deps/__init__.py b/src/apm_cli/commands/deps/__init__.py
new file mode 100644
index 000000000..e341d3272
--- /dev/null
+++ b/src/apm_cli/commands/deps/__init__.py
@@ -0,0 +1,34 @@
+"""APM dependency management commands."""
+
+from .cli import deps, list_packages, tree, clean, update, info
+from ._utils import (
+    _is_nested_under_package,
+    _count_primitives,
+    _count_package_files,
+    _count_workflows,
+    _get_detailed_context_counts,
+    _get_package_display_info,
+    _get_detailed_package_info,
+    _update_single_package,
+    _update_all_packages,
+)
+
+__all__ = [
+    # CLI commands
+    "deps",
+    "list_packages",
+    "tree",
+    "clean",
+    "update",
+    "info",
+    # Utility functions (used by tests)
+    "_is_nested_under_package",
+    "_count_primitives",
+    "_count_package_files",
+    "_count_workflows",
+    "_get_detailed_context_counts",
+    "_get_package_display_info",
+    "_get_detailed_package_info",
+    "_update_single_package",
+    "_update_all_packages",
+]
diff --git a/src/apm_cli/commands/deps/_utils.py b/src/apm_cli/commands/deps/_utils.py
new file mode 100644
index 000000000..bebc6a428
--- /dev/null
+++ b/src/apm_cli/commands/deps/_utils.py
@@ -0,0 +1,296 @@
+"""Utility helpers for APM dependency commands."""
+
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+from ...constants import APM_DIR, APM_MODULES_DIR, APM_YML_FILENAME, SKILL_MD_FILENAME
+from ...models.apm_package import APMPackage
+from ...deps.github_downloader import GitHubPackageDownloader
+from ...utils.console import _rich_error, _rich_info, _rich_success, _rich_warning
+
+
+def _is_nested_under_package(candidate: Path, apm_modules_path: Path) -> bool:
+    """Check if *candidate* is a sub-directory of another installed package.
+
+    When a plugin ships ``skills/*/SKILL.md`` at its root (outside ``.apm/``),
+    the ``rglob`` scan would otherwise treat each skill sub-directory as an
+    independent package.  This helper walks up from *candidate* towards
+    *apm_modules_path* and returns ``True`` if any intermediate parent already
+    contains ``apm.yml``  -- meaning the candidate is a deployment artifact, not
+    a standalone package.
+    """
+    parent = candidate.parent
+    while parent != apm_modules_path and parent != parent.parent:
+        if (parent / APM_YML_FILENAME).exists():
+            return True
+        parent = parent.parent
+    return False
+
+
+def _count_primitives(package_path: Path) -> Dict[str, int]:
+    """Count primitives by type in a package.
+    
+    Returns:
+        dict: Counts for 'prompts', 'instructions', 'agents', 'skills'
+    """
+    counts = {'prompts': 0, 'instructions': 0, 'agents': 0, 'skills': 0, 'hooks': 0}
+    
+    apm_dir = package_path / APM_DIR
+    if apm_dir.exists():
+        for subdir, key, pattern in [
+            ("prompts", "prompts", "*.prompt.md"),
+            ("instructions", "instructions", "*.md"),
+            ("agents", "agents", "*.md"),
+        ]:
+            path = apm_dir / subdir
+            if path.exists() and path.is_dir():
+                counts[key] += len(list(path.glob(pattern)))
+        
+        skills_path = apm_dir / "skills"
+        if skills_path.exists() and skills_path.is_dir():
+            counts['skills'] += len([d for d in skills_path.iterdir() 
+                                     if d.is_dir() and (d / SKILL_MD_FILENAME).exists()])
+    
+    # Also count root-level .prompt.md files
+    counts['prompts'] += len(list(package_path.glob("*.prompt.md")))
+    
+    # Count root-level SKILL.md as a skill
+    if (package_path / SKILL_MD_FILENAME).exists():
+        counts['skills'] += 1
+    
+    # Count hooks (.json files in hooks/ or .apm/hooks/)
+    for hooks_dir in [package_path / "hooks", apm_dir / "hooks" if apm_dir.exists() else None]:
+        if hooks_dir and hooks_dir.exists() and hooks_dir.is_dir():
+            counts['hooks'] += len(list(hooks_dir.glob("*.json")))
+    
+    return counts
+
+
+def _count_package_files(package_path: Path) -> tuple[int, int]:
+    """Count context files and workflows in a package.
+    
+    Returns:
+        tuple: (context_count, workflow_count)
+    """
+    apm_dir = package_path / APM_DIR
+    if not apm_dir.exists():
+        # Also check root directory for .prompt.md files
+        workflow_count = len(list(package_path.glob("*.prompt.md")))
+        return 0, workflow_count
+    
+    context_count = 0
+    context_dirs = ['instructions', 'chatmodes', 'contexts']
+    
+    for context_dir in context_dirs:
+        context_path = apm_dir / context_dir
+        if context_path.exists() and context_path.is_dir():
+            context_count += len(list(context_path.glob("*.md")))
+    
+    # Count workflows in both .apm/prompts and root directory
+    workflow_count = 0
+    prompts_path = apm_dir / "prompts"
+    if prompts_path.exists() and prompts_path.is_dir():
+        workflow_count += len(list(prompts_path.glob("*.prompt.md")))
+    
+    # Also check root directory for .prompt.md files
+    workflow_count += len(list(package_path.glob("*.prompt.md")))
+    
+    return context_count, workflow_count
+
+
+def _count_workflows(package_path: Path) -> int:
+    """Count agent workflows (.prompt.md files) in a package."""
+    _, workflow_count = _count_package_files(package_path)
+    return workflow_count
+
+
+def _get_detailed_context_counts(package_path: Path) -> Dict[str, int]:
+    """Get detailed context file counts by type."""
+    apm_dir = package_path / APM_DIR
+    if not apm_dir.exists():
+        return {'instructions': 0, 'chatmodes': 0, 'contexts': 0}
+    
+    counts = {}
+    context_directories = {
+        'instructions': 'instructions',
+        'chatmodes': 'chatmodes', 
+        'contexts': 'context'  # Note: directory is 'context', not 'contexts'
+    }
+    
+    for context_type, directory_name in context_directories.items():
+        count = 0
+        context_path = apm_dir / directory_name
+        if context_path.exists() and context_path.is_dir():
+            # Count all .md files in the directory regardless of specific naming
+            count = len(list(context_path.glob("*.md")))
+        counts[context_type] = count
+    
+    return counts
+
+
+def _get_package_display_info(package_path: Path) -> Dict[str, str]:
+    """Get package display information."""
+    try:
+        apm_yml_path = package_path / APM_YML_FILENAME
+        if apm_yml_path.exists():
+            package = APMPackage.from_apm_yml(apm_yml_path)
+            version_info = f"@{package.version}" if package.version else "@unknown"
+            return {
+                'display_name': f"{package.name}{version_info}",
+                'name': package.name,
+                'version': package.version or 'unknown'
+            }
+        else:
+            return {
+                'display_name': f"{package_path.name}@unknown",
+                'name': package_path.name,
+                'version': 'unknown'
+            }
+    except Exception:
+        return {
+            'display_name': f"{package_path.name}@error",
+            'name': package_path.name,
+            'version': 'error'
+        }
+
+
+def _get_detailed_package_info(package_path: Path) -> Dict[str, Any]:
+    """Get detailed package information for the info command."""
+    try:
+        apm_yml_path = package_path / APM_YML_FILENAME
+        if apm_yml_path.exists():
+            package = APMPackage.from_apm_yml(apm_yml_path)
+            context_count, workflow_count = _count_package_files(package_path)
+            primitives = _count_primitives(package_path)
+            return {
+                'name': package.name,
+                'version': package.version or 'unknown',
+                'description': package.description or 'No description',
+                'author': package.author or 'Unknown',
+                'source': package.source or 'local',
+                'install_path': str(package_path.resolve()),
+                'context_files': _get_detailed_context_counts(package_path),
+                'workflows': workflow_count,
+                'hooks': primitives.get('hooks', 0)
+            }
+        else:
+            context_count, workflow_count = _count_package_files(package_path)
+            primitives = _count_primitives(package_path)
+            return {
+                'name': package_path.name,
+                'version': 'unknown',
+                'description': 'No apm.yml found',
+                'author': 'Unknown',
+                'source': 'unknown',
+                'install_path': str(package_path.resolve()),
+                'context_files': _get_detailed_context_counts(package_path),
+                'workflows': workflow_count,
+                'hooks': primitives.get('hooks', 0)
+            }
+    except Exception as e:
+        return {
+            'name': package_path.name,
+            'version': 'error',
+            'description': f'Error loading package: {e}',
+            'author': 'Unknown',
+            'source': 'unknown',
+            'install_path': str(package_path.resolve()),
+            'context_files': {'instructions': 0, 'chatmodes': 0, 'contexts': 0},
+            'workflows': 0,
+            'hooks': 0
+        }
+
+
+def _update_single_package(package_name: str, project_deps: List, apm_modules_path: Path):
+    """Update a specific package."""
+    # Find the dependency reference for this package
+    target_dep = None
+    for dep in project_deps:
+        if dep.get_display_name() == package_name or dep.repo_url.split('/')[-1] == package_name:
+            target_dep = dep
+            break
+    
+    if not target_dep:
+        _rich_error(f"Package '{package_name}' not found in apm.yml dependencies")
+        return
+    
+    # Find the installed package directory using namespaced structure
+    # GitHub: owner/repo (2 parts)
+    # Azure DevOps: org/project/repo (3 parts)
+    package_dir = None
+    if target_dep.alias:
+        package_dir = apm_modules_path / target_dep.alias
+    else:
+        # Parse path from repo_url
+        repo_parts = target_dep.repo_url.split('/')
+        if target_dep.is_azure_devops() and len(repo_parts) >= 3:
+            # ADO structure: apm_modules/org/project/repo
+            package_dir = apm_modules_path / repo_parts[0] / repo_parts[1] / repo_parts[2]
+        elif len(repo_parts) >= 2:
+            package_dir = apm_modules_path / repo_parts[0] / repo_parts[1]
+        else:
+            # Fallback to simple name matching
+            package_dir = apm_modules_path / package_name
+        
+    if not package_dir.exists():
+        _rich_error(f"Package '{package_name}' not installed in apm_modules/")
+        _rich_info(f"Run 'apm install' to install it first")
+        return
+    
+    try:
+        downloader = GitHubPackageDownloader()
+        _rich_info(f"Updating {target_dep.repo_url}...")
+        
+        # Download latest version
+        package_info = downloader.download_package(str(target_dep), package_dir)
+        
+        _rich_success(f"[+] Updated {target_dep.repo_url}")
+        
+    except Exception as e:
+        _rich_error(f"Failed to update {package_name}: {e}")
+
+
+def _update_all_packages(project_deps: List, apm_modules_path: Path):
+    """Update all packages."""
+    if not project_deps:
+        _rich_info("No APM dependencies to update")
+        return
+        
+    _rich_info(f"Updating {len(project_deps)} APM dependencies...")
+    
+    downloader = GitHubPackageDownloader()
+    updated_count = 0
+    
+    for dep in project_deps:
+        # Determine package directory using namespaced structure
+        # GitHub: apm_modules/owner/repo (2 parts)
+        # Azure DevOps: apm_modules/org/project/repo (3 parts)
+        if dep.alias:
+            package_dir = apm_modules_path / dep.alias
+        else:
+            # Parse path from repo_url
+            repo_parts = dep.repo_url.split('/')
+            if dep.is_azure_devops() and len(repo_parts) >= 3:
+                # ADO structure
+                package_dir = apm_modules_path / repo_parts[0] / repo_parts[1] / repo_parts[2]
+            elif len(repo_parts) >= 2:
+                package_dir = apm_modules_path / repo_parts[0] / repo_parts[1]
+            else:
+                # Fallback to simple repo name (shouldn't happen)
+                package_dir = apm_modules_path / dep.repo_url
+            
+        if not package_dir.exists():
+            _rich_warning(f"[!] {dep.repo_url} not installed - skipping")
+            continue
+            
+        try:
+            _rich_info(f"  Updating {dep.repo_url}...")
+            package_info = downloader.download_package(str(dep), package_dir)
+            updated_count += 1
+            _rich_success(f"  [+] {dep.repo_url}")
+            
+        except Exception as e:
+            _rich_error(f"  [x] Failed to update {dep.repo_url}: {e}")
+            continue
+    
+    _rich_success(f"Updated {updated_count} of {len(project_deps)} packages")
diff --git a/src/apm_cli/commands/deps.py b/src/apm_cli/commands/deps/cli.py
similarity index 61%
rename from src/apm_cli/commands/deps.py
rename to src/apm_cli/commands/deps/cli.py
index 17f4379f0..a14631a5e 100644
--- a/src/apm_cli/commands/deps.py
+++ b/src/apm_cli/commands/deps/cli.py
@@ -1,4 +1,4 @@
-"""APM dependency management commands."""
+"""APM dependency management CLI commands."""
 
 import sys
 import shutil
@@ -7,13 +7,25 @@
 from typing import List, Optional, Dict, Any
 
 # Import existing APM components
-from ..models.apm_package import APMPackage, ValidationResult, validate_apm_package
-from ..utils.console import _rich_success, _rich_error, _rich_info, _rich_warning
+from ...constants import APM_DIR, APM_MODULES_DIR, APM_YML_FILENAME, SKILL_MD_FILENAME
+from ...models.apm_package import APMPackage, ValidationResult, validate_apm_package
+from ...utils.console import _rich_success, _rich_error, _rich_info, _rich_warning
 
 # Import APM dependency system components (with fallback)
-from ..deps.github_downloader import GitHubPackageDownloader
-from ..deps.apm_resolver import APMDependencyResolver
-
+from ...deps.github_downloader import GitHubPackageDownloader
+from ...deps.apm_resolver import APMDependencyResolver
+
+from ._utils import (
+    _is_nested_under_package,
+    _count_primitives,
+    _count_package_files,
+    _count_workflows,
+    _get_detailed_context_counts,
+    _get_package_display_info,
+    _get_detailed_package_info,
+    _update_single_package,
+    _update_all_packages,
+)
 
 
 @click.group(help="Manage APM package dependencies")
@@ -39,7 +51,7 @@ def list_packages():
     
     try:
         project_root = Path(".")
-        apm_modules_path = project_root / "apm_modules"
+        apm_modules_path = project_root / APM_MODULES_DIR
         
         # Check if apm_modules exists
         if not apm_modules_path.exists():
@@ -56,49 +68,48 @@ def list_packages():
         # Azure DevOps: org/project/repo or org/project/virtual-pkg-name (3 levels)
         declared_sources = {}  # dep_path -> 'github' | 'azure-devops'
         try:
-            apm_yml_path = project_root / "apm.yml"
+            apm_yml_path = project_root / APM_YML_FILENAME
             if apm_yml_path.exists():
                 project_package = APMPackage.from_apm_yml(apm_yml_path)
                 for dep in project_package.get_apm_dependencies():
                     # Build the expected installed package name
                     repo_parts = dep.repo_url.split('/')
                     source = 'azure-devops' if dep.is_azure_devops() else 'github'
-                    if dep.is_virtual:
-                        if dep.is_virtual_subdirectory() and dep.virtual_path:
-                            # Virtual subdirectory packages keep natural path structure.
-                            # GitHub: owner/repo/subdir
-                            # ADO: org/project/repo/subdir
-                            if dep.is_azure_devops() and len(repo_parts) >= 3:
-                                declared_sources[
-                                    f"{repo_parts[0]}/{repo_parts[1]}/{repo_parts[2]}/{dep.virtual_path}"
-                                ] = source
-                            elif len(repo_parts) >= 2:
-                                declared_sources[
-                                    f"{repo_parts[0]}/{repo_parts[1]}/{dep.virtual_path}"
-                                ] = source
-                        else:
-                            # Virtual file/collection packages are flattened.
-                            package_name = dep.get_virtual_package_name()
-                            if dep.is_azure_devops() and len(repo_parts) >= 3:
-                                # ADO structure: org/project/virtual-pkg-name
-                                declared_sources[f"{repo_parts[0]}/{repo_parts[1]}/{package_name}"] = source
-                            elif len(repo_parts) >= 2:
-                                # GitHub structure: owner/virtual-pkg-name
-                                declared_sources[f"{repo_parts[0]}/{package_name}"] = source
-                    else:
+                    is_ado = dep.is_azure_devops() and len(repo_parts) >= 3
+                    is_gh = len(repo_parts) >= 2
+
+                    if not dep.is_virtual:
                         # Regular package: use full repo_url path
-                        if dep.is_azure_devops() and len(repo_parts) >= 3:
-                            # ADO structure: org/project/repo
+                        if is_ado:
                             declared_sources[f"{repo_parts[0]}/{repo_parts[1]}/{repo_parts[2]}"] = source
-                        elif len(repo_parts) >= 2:
-                            # GitHub structure: owner/repo
+                        elif is_gh:
                             declared_sources[f"{repo_parts[0]}/{repo_parts[1]}"] = source
+                        continue
+
+                    if dep.is_virtual_subdirectory() and dep.virtual_path:
+                        # Virtual subdirectory packages keep natural path structure.
+                        if is_ado:
+                            declared_sources[
+                                f"{repo_parts[0]}/{repo_parts[1]}/{repo_parts[2]}/{dep.virtual_path}"
+                            ] = source
+                        elif is_gh:
+                            declared_sources[
+                                f"{repo_parts[0]}/{repo_parts[1]}/{dep.virtual_path}"
+                            ] = source
+                        continue
+
+                    # Virtual file/collection packages are flattened.
+                    package_name = dep.get_virtual_package_name()
+                    if is_ado:
+                        declared_sources[f"{repo_parts[0]}/{repo_parts[1]}/{package_name}"] = source
+                    elif is_gh:
+                        declared_sources[f"{repo_parts[0]}/{package_name}"] = source
         except Exception:
             pass  # Continue without orphan detection if apm.yml parsing fails
         
         # Also load lockfile deps to avoid false orphan flags on transitive deps
         try:
-            from ..deps.lockfile import LockFile, get_lockfile_path
+            from ...deps.lockfile import LockFile, get_lockfile_path
             lockfile_path = get_lockfile_path(project_root)
             if lockfile_path.exists():
                 lockfile = LockFile.read(lockfile_path)
@@ -118,8 +129,8 @@ def list_packages():
         for candidate in apm_modules_path.rglob("*"):
             if not candidate.is_dir() or candidate.name.startswith('.'):
                 continue
-            has_apm_yml = (candidate / "apm.yml").exists()
-            has_skill_md = (candidate / "SKILL.md").exists()
+            has_apm_yml = (candidate / APM_YML_FILENAME).exists()
+            has_skill_md = (candidate / SKILL_MD_FILENAME).exists()
             if not has_apm_yml and not has_skill_md:
                 continue
             rel_parts = candidate.relative_to(apm_modules_path).parts
@@ -133,14 +144,13 @@ def list_packages():
 
             # Skip skill sub-dirs nested inside another package (e.g. plugin
             # skills/ directories that are deployment artifacts, not packages).
-            if has_skill_md and not has_apm_yml:
-                if _is_nested_under_package(candidate, apm_modules_path):
-                    continue
+            if has_skill_md and not has_apm_yml and _is_nested_under_package(candidate, apm_modules_path):
+                continue
 
             try:
                 version = 'unknown'
                 if has_apm_yml:
-                    package = APMPackage.from_apm_yml(candidate / "apm.yml")
+                    package = APMPackage.from_apm_yml(candidate / APM_YML_FILENAME)
                     version = package.version or 'unknown'
                 primitives = _count_primitives(candidate)
                 
@@ -244,12 +254,12 @@ def tree():
     
     try:
         project_root = Path(".")
-        apm_modules_path = project_root / "apm_modules"
+        apm_modules_path = project_root / APM_MODULES_DIR
         
         # Load project info
         project_name = "my-project"
         try:
-            apm_yml_path = project_root / "apm.yml"
+            apm_yml_path = project_root / APM_YML_FILENAME
             if apm_yml_path.exists():
                 root_package = APMPackage.from_apm_yml(apm_yml_path)
                 project_name = root_package.name
@@ -259,7 +269,7 @@ def tree():
         # Try to load lockfile for accurate tree with depth/parent info
         lockfile_deps = None
         try:
-            from ..deps.lockfile import LockFile, get_lockfile_path
+            from ...deps.lockfile import LockFile, get_lockfile_path
             lockfile_path = get_lockfile_path(project_root)
             if lockfile_path.exists():
                 lockfile = LockFile.read(lockfile_path)
@@ -356,8 +366,8 @@ def _add_children(parent_branch, parent_repo_url, depth=0):
                     for candidate in sorted(apm_modules_path.rglob("*")):
                         if not candidate.is_dir() or candidate.name.startswith('.'):
                             continue
-                        has_apm = (candidate / "apm.yml").exists()
-                        has_skill = (candidate / "SKILL.md").exists()
+                        has_apm = (candidate / APM_YML_FILENAME).exists()
+                        has_skill = (candidate / SKILL_MD_FILENAME).exists()
                         if not has_apm and not has_skill:
                             continue
                         rel_parts = candidate.relative_to(apm_modules_path).parts
@@ -393,7 +403,7 @@ def _add_children(parent_branch, parent_repo_url, depth=0):
 def clean():
     """Remove entire apm_modules/ directory."""
     project_root = Path(".")
-    apm_modules_path = project_root / "apm_modules"
+    apm_modules_path = project_root / APM_MODULES_DIR
     
     if not apm_modules_path.exists():
         _rich_info("No apm_modules/ directory found - already clean")
@@ -429,7 +439,7 @@ def update(package: Optional[str]):
     """Update specific package or all if no package specified."""
     
     project_root = Path(".")
-    apm_modules_path = project_root / "apm_modules"
+    apm_modules_path = project_root / APM_MODULES_DIR
     
     if not apm_modules_path.exists():
         _rich_info("No apm_modules/ directory found - no packages to update")
@@ -437,9 +447,9 @@ def update(package: Optional[str]):
     
     # Get project dependencies to validate updates
     try:
-        apm_yml_path = project_root / "apm.yml"
+        apm_yml_path = project_root / APM_YML_FILENAME
         if not apm_yml_path.exists():
-            _rich_error("No apm.yml found in current directory")
+            _rich_error(f"No {APM_YML_FILENAME} found in current directory")
             return
             
         project_package = APMPackage.from_apm_yml(apm_yml_path)
@@ -450,7 +460,7 @@ def update(package: Optional[str]):
             return
             
     except Exception as e:
-        _rich_error(f"Error reading apm.yml: {e}")
+        _rich_error(f"Error reading {APM_YML_FILENAME}: {e}")
         return
     
     if package:
@@ -466,7 +476,7 @@ def update(package: Optional[str]):
 def info(package: str):
     """Show detailed information about a specific package including context files and workflows."""
     project_root = Path(".")
-    apm_modules_path = project_root / "apm_modules"
+    apm_modules_path = project_root / APM_MODULES_DIR
     
     if not apm_modules_path.exists():
         _rich_error("No apm_modules/ directory found")
@@ -478,7 +488,7 @@ def info(package: str):
     # First try direct path match (handles any depth: org/repo, org/repo/subdir/pkg)
     direct_match = apm_modules_path / package
     if direct_match.is_dir() and (
-        (direct_match / "apm.yml").exists() or (direct_match / "SKILL.md").exists()
+        (direct_match / APM_YML_FILENAME).exists() or (direct_match / SKILL_MD_FILENAME).exists()
     ):
         package_path = direct_match
     else:
@@ -583,297 +593,3 @@ def info(package: str):
     except Exception as e:
         _rich_error(f"Error reading package information: {e}")
         sys.exit(1)
-
-
-# Helper functions
-
-
-def _is_nested_under_package(candidate: Path, apm_modules_path: Path) -> bool:
-    """Check if *candidate* is a sub-directory of another installed package.
-
-    When a plugin ships ``skills/*/SKILL.md`` at its root (outside ``.apm/``),
-    the ``rglob`` scan would otherwise treat each skill sub-directory as an
-    independent package.  This helper walks up from *candidate* towards
-    *apm_modules_path* and returns ``True`` if any intermediate parent already
-    contains ``apm.yml``  -- meaning the candidate is a deployment artifact, not
-    a standalone package.
-    """
-    parent = candidate.parent
-    while parent != apm_modules_path and parent != parent.parent:
-        if (parent / "apm.yml").exists():
-            return True
-        parent = parent.parent
-    return False
-
-
-def _count_primitives(package_path: Path) -> Dict[str, int]:
-    """Count primitives by type in a package.
-    
-    Returns:
-        dict: Counts for 'prompts', 'instructions', 'agents', 'skills'
-    """
-    counts = {'prompts': 0, 'instructions': 0, 'agents': 0, 'skills': 0, 'hooks': 0}
-    
-    apm_dir = package_path / ".apm"
-    if apm_dir.exists():
-        prompts_path = apm_dir / "prompts"
-        if prompts_path.exists() and prompts_path.is_dir():
-            counts['prompts'] += len(list(prompts_path.glob("*.prompt.md")))
-        
-        instructions_path = apm_dir / "instructions"
-        if instructions_path.exists() and instructions_path.is_dir():
-            counts['instructions'] += len(list(instructions_path.glob("*.md")))
-        
-        agents_path = apm_dir / "agents"
-        if agents_path.exists() and agents_path.is_dir():
-            counts['agents'] += len(list(agents_path.glob("*.md")))
-        
-        skills_path = apm_dir / "skills"
-        if skills_path.exists() and skills_path.is_dir():
-            counts['skills'] += len([d for d in skills_path.iterdir() 
-                                     if d.is_dir() and (d / "SKILL.md").exists()])
-    
-    # Also count root-level .prompt.md files
-    counts['prompts'] += len(list(package_path.glob("*.prompt.md")))
-    
-    # Count root-level SKILL.md as a skill
-    if (package_path / "SKILL.md").exists():
-        counts['skills'] += 1
-    
-    # Count hooks (.json files in hooks/ or .apm/hooks/)
-    for hooks_dir in [package_path / "hooks", apm_dir / "hooks" if apm_dir.exists() else None]:
-        if hooks_dir and hooks_dir.exists() and hooks_dir.is_dir():
-            counts['hooks'] += len(list(hooks_dir.glob("*.json")))
-    
-    return counts
-
-
-def _count_package_files(package_path: Path) -> tuple[int, int]:
-    """Count context files and workflows in a package.
-    
-    Returns:
-        tuple: (context_count, workflow_count)
-    """
-    apm_dir = package_path / ".apm"
-    if not apm_dir.exists():
-        # Also check root directory for .prompt.md files
-        workflow_count = len(list(package_path.glob("*.prompt.md")))
-        return 0, workflow_count
-    
-    context_count = 0
-    context_dirs = ['instructions', 'chatmodes', 'contexts']
-    
-    for context_dir in context_dirs:
-        context_path = apm_dir / context_dir
-        if context_path.exists() and context_path.is_dir():
-            context_count += len(list(context_path.glob("*.md")))
-    
-    # Count workflows in both .apm/prompts and root directory
-    workflow_count = 0
-    prompts_path = apm_dir / "prompts"
-    if prompts_path.exists() and prompts_path.is_dir():
-        workflow_count += len(list(prompts_path.glob("*.prompt.md")))
-    
-    # Also check root directory for .prompt.md files
-    workflow_count += len(list(package_path.glob("*.prompt.md")))
-    
-    return context_count, workflow_count
-
-
-def _count_workflows(package_path: Path) -> int:
-    """Count agent workflows (.prompt.md files) in a package."""
-    _, workflow_count = _count_package_files(package_path)
-    return workflow_count
-
-
-def _get_detailed_context_counts(package_path: Path) -> Dict[str, int]:
-    """Get detailed context file counts by type."""
-    apm_dir = package_path / ".apm"
-    if not apm_dir.exists():
-        return {'instructions': 0, 'chatmodes': 0, 'contexts': 0}
-    
-    counts = {}
-    context_directories = {
-        'instructions': 'instructions',
-        'chatmodes': 'chatmodes', 
-        'contexts': 'context'  # Note: directory is 'context', not 'contexts'
-    }
-    
-    for context_type, directory_name in context_directories.items():
-        count = 0
-        context_path = apm_dir / directory_name
-        if context_path.exists() and context_path.is_dir():
-            # Count all .md files in the directory regardless of specific naming
-            count = len(list(context_path.glob("*.md")))
-        counts[context_type] = count
-    
-    return counts
-
-
-def _get_package_display_info(package_path: Path) -> Dict[str, str]:
-    """Get package display information."""
-    try:
-        apm_yml_path = package_path / "apm.yml"
-        if apm_yml_path.exists():
-            package = APMPackage.from_apm_yml(apm_yml_path)
-            version_info = f"@{package.version}" if package.version else "@unknown"
-            return {
-                'display_name': f"{package.name}{version_info}",
-                'name': package.name,
-                'version': package.version or 'unknown'
-            }
-        else:
-            return {
-                'display_name': f"{package_path.name}@unknown",
-                'name': package_path.name,
-                'version': 'unknown'
-            }
-    except Exception:
-        return {
-            'display_name': f"{package_path.name}@error",
-            'name': package_path.name,
-            'version': 'error'
-        }
-
-
-def _get_detailed_package_info(package_path: Path) -> Dict[str, Any]:
-    """Get detailed package information for the info command."""
-    try:
-        apm_yml_path = package_path / "apm.yml"
-        if apm_yml_path.exists():
-            package = APMPackage.from_apm_yml(apm_yml_path)
-            context_count, workflow_count = _count_package_files(package_path)
-            primitives = _count_primitives(package_path)
-            return {
-                'name': package.name,
-                'version': package.version or 'unknown',
-                'description': package.description or 'No description',
-                'author': package.author or 'Unknown',
-                'source': package.source or 'local',
-                'install_path': str(package_path.resolve()),
-                'context_files': _get_detailed_context_counts(package_path),
-                'workflows': workflow_count,
-                'hooks': primitives.get('hooks', 0)
-            }
-        else:
-            context_count, workflow_count = _count_package_files(package_path)
-            primitives = _count_primitives(package_path)
-            return {
-                'name': package_path.name,
-                'version': 'unknown',
-                'description': 'No apm.yml found',
-                'author': 'Unknown',
-                'source': 'unknown',
-                'install_path': str(package_path.resolve()),
-                'context_files': _get_detailed_context_counts(package_path),
-                'workflows': workflow_count,
-                'hooks': primitives.get('hooks', 0)
-            }
-    except Exception as e:
-        return {
-            'name': package_path.name,
-            'version': 'error',
-            'description': f'Error loading package: {e}',
-            'author': 'Unknown',
-            'source': 'unknown',
-            'install_path': str(package_path.resolve()),
-            'context_files': {'instructions': 0, 'chatmodes': 0, 'contexts': 0},
-            'workflows': 0,
-            'hooks': 0
-        }
-
-
-def _update_single_package(package_name: str, project_deps: List, apm_modules_path: Path):
-    """Update a specific package."""
-    # Find the dependency reference for this package
-    target_dep = None
-    for dep in project_deps:
-        if dep.get_display_name() == package_name or dep.repo_url.split('/')[-1] == package_name:
-            target_dep = dep
-            break
-    
-    if not target_dep:
-        _rich_error(f"Package '{package_name}' not found in apm.yml dependencies")
-        return
-    
-    # Find the installed package directory using namespaced structure
-    # GitHub: owner/repo (2 parts)
-    # Azure DevOps: org/project/repo (3 parts)
-    package_dir = None
-    if target_dep.alias:
-        package_dir = apm_modules_path / target_dep.alias
-    else:
-        # Parse path from repo_url
-        repo_parts = target_dep.repo_url.split('/')
-        if target_dep.is_azure_devops() and len(repo_parts) >= 3:
-            # ADO structure: apm_modules/org/project/repo
-            package_dir = apm_modules_path / repo_parts[0] / repo_parts[1] / repo_parts[2]
-        elif len(repo_parts) >= 2:
-            package_dir = apm_modules_path / repo_parts[0] / repo_parts[1]
-        else:
-            # Fallback to simple name matching
-            package_dir = apm_modules_path / package_name
-        
-    if not package_dir.exists():
-        _rich_error(f"Package '{package_name}' not installed in apm_modules/")
-        _rich_info(f"Run 'apm install' to install it first")
-        return
-    
-    try:
-        downloader = GitHubPackageDownloader()
-        _rich_info(f"Updating {target_dep.repo_url}...")
-        
-        # Download latest version
-        package_info = downloader.download_package(str(target_dep), package_dir)
-        
-        _rich_success(f"[+] Updated {target_dep.repo_url}")
-        
-    except Exception as e:
-        _rich_error(f"Failed to update {package_name}: {e}")
-
-
-def _update_all_packages(project_deps: List, apm_modules_path: Path):
-    """Update all packages."""
-    if not project_deps:
-        _rich_info("No APM dependencies to update")
-        return
-        
-    _rich_info(f"Updating {len(project_deps)} APM dependencies...")
-    
-    downloader = GitHubPackageDownloader()
-    updated_count = 0
-    
-    for dep in project_deps:
-        # Determine package directory using namespaced structure
-        # GitHub: apm_modules/owner/repo (2 parts)
-        # Azure DevOps: apm_modules/org/project/repo (3 parts)
-        if dep.alias:
-            package_dir = apm_modules_path / dep.alias
-        else:
-            # Parse path from repo_url
-            repo_parts = dep.repo_url.split('/')
-            if dep.is_azure_devops() and len(repo_parts) >= 3:
-                # ADO structure
-                package_dir = apm_modules_path / repo_parts[0] / repo_parts[1] / repo_parts[2]
-            elif len(repo_parts) >= 2:
-                package_dir = apm_modules_path / repo_parts[0] / repo_parts[1]
-            else:
-                # Fallback to simple repo name (shouldn't happen)
-                package_dir = apm_modules_path / dep.repo_url
-            
-        if not package_dir.exists():
-            _rich_warning(f"[!] {dep.repo_url} not installed - skipping")
-            continue
-            
-        try:
-            _rich_info(f"  Updating {dep.repo_url}...")
-            package_info = downloader.download_package(str(dep), package_dir)
-            updated_count += 1
-            _rich_success(f"  [+] {dep.repo_url}")
-            
-        except Exception as e:
-            _rich_error(f"  [x] Failed to update {dep.repo_url}: {e}")
-            continue
-    
-    _rich_success(f"Updated {updated_count} of {len(project_deps)} packages")
-
diff --git a/src/apm_cli/commands/init.py b/src/apm_cli/commands/init.py
index b0f2351bb..e54021c92 100644
--- a/src/apm_cli/commands/init.py
+++ b/src/apm_cli/commands/init.py
@@ -6,6 +6,7 @@
 
 import click
 
+from ..constants import APM_YML_FILENAME
 from ..utils.console import (
     _create_files_table,
     _rich_echo,
@@ -54,7 +55,7 @@ def init(ctx, project_name, yes):
             final_project_name = project_dir.name
 
         # Check for existing apm.yml
-        apm_yml_exists = Path("apm.yml").exists()
+        apm_yml_exists = Path(APM_YML_FILENAME).exists()
 
         # Handle existing apm.yml in brownfield projects
         if apm_yml_exists:
@@ -95,7 +96,7 @@ def init(ctx, project_name, yes):
             console = _get_console()
             if console:
                 files_data = [
-                    ("*", "apm.yml", "Project configuration"),
+                    ("*", APM_YML_FILENAME, "Project configuration"),
                 ]
                 table = _create_files_table(files_data, title="Created Files")
                 console.print(table)
diff --git a/src/apm_cli/commands/install.py b/src/apm_cli/commands/install.py
index 42d198264..0b524cc90 100644
--- a/src/apm_cli/commands/install.py
+++ b/src/apm_cli/commands/install.py
@@ -7,7 +7,17 @@
 
 import click
 
+from ..constants import (
+    APM_LOCK_FILENAME,
+    APM_MODULES_DIR,
+    APM_YML_FILENAME,
+    GITHUB_DIR,
+    CLAUDE_DIR,
+    SKILL_MD_FILENAME,
+    InstallMode,
+)
 from ..drift import build_download_ref, detect_orphans, detect_ref_change
+from ..models.results import InstallResult
 from ..utils.console import _rich_error, _rich_info, _rich_success, _rich_warning
 from ..utils.diagnostics import DiagnosticCollector
 from ..utils.github_host import default_host, is_valid_fqdn
@@ -59,14 +69,14 @@ def _validate_and_add_packages_to_apm_yml(packages, dry_run=False):
 
     import yaml
 
-    apm_yml_path = Path("apm.yml")
+    apm_yml_path = Path(APM_YML_FILENAME)
 
     # Read current apm.yml
     try:
         with open(apm_yml_path, "r") as f:
             data = yaml.safe_load(f) or {}
     except Exception as e:
-        _rich_error(f"Failed to read apm.yml: {e}")
+        _rich_error(f"Failed to read {APM_YML_FILENAME}: {e}")
         sys.exit(1)
 
     # Ensure dependencies structure exists
@@ -152,9 +162,9 @@ def _validate_and_add_packages_to_apm_yml(packages, dry_run=False):
     try:
         with open(apm_yml_path, "w") as f:
             yaml.safe_dump(data, f, default_flow_style=False, sort_keys=False)
-        _rich_success(f"Updated apm.yml with {len(validated_packages)} new package(s)")
+        _rich_success(f"Updated {APM_YML_FILENAME} with {len(validated_packages)} new package(s)")
     except Exception as e:
-        _rich_error(f"Failed to write apm.yml: {e}")
+        _rich_error(f"Failed to write {APM_YML_FILENAME}: {e}")
         sys.exit(1)
 
     return validated_packages
@@ -301,7 +311,11 @@ 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 and deploy despite critical security findings")
-@click.option("--verbose", is_flag=True, help="Show detailed installation information")
+@click.option(
+    "--verbose",
+    is_flag=True,
+    help="Show individual file paths and full error details in the diagnostic summary",
+)
 @click.option(
     "--trust-transitive-mcp",
     is_flag=True,
@@ -322,6 +336,9 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo
     MCP servers for all detected and available runtimes. It also installs APM package
     dependencies from GitHub repositories.
 
+    The --only flag filters by dependency type (apm or mcp). Internally converted
+    to an InstallMode enum for type-safe dispatch.
+
     Examples:
         apm install                             # Install existing deps from apm.yml
         apm install org/pkg1                    # Add package to apm.yml and install
@@ -334,7 +351,7 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo
     """
     try:
         # Check if apm.yml exists
-        apm_yml_exists = Path("apm.yml").exists()
+        apm_yml_exists = Path(APM_YML_FILENAME).exists()
 
         # Auto-bootstrap: create minimal apm.yml when packages specified but no apm.yml
         if not apm_yml_exists and packages:
@@ -342,11 +359,11 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo
             project_name = Path.cwd().name
             config = _get_default_config(project_name)
             _create_minimal_apm_yml(config)
-            _rich_success("Created apm.yml", symbol="sparkles")
+            _rich_success(f"Created {APM_YML_FILENAME}", 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_error(f"No {APM_YML_FILENAME} found")
             _rich_info("💡 Run 'apm init' to create one, or:")
             _rich_info("   apm install <org/repo> to auto-create + install")
             sys.exit(1)
@@ -363,18 +380,24 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo
 
         # Parse apm.yml to get both APM and MCP dependencies
         try:
-            apm_package = APMPackage.from_apm_yml(Path("apm.yml"))
+            apm_package = APMPackage.from_apm_yml(Path(APM_YML_FILENAME))
         except Exception as e:
-            _rich_error(f"Failed to parse apm.yml: {e}")
+            _rich_error(f"Failed to parse {APM_YML_FILENAME}: {e}")
             sys.exit(1)
 
         # Get APM and MCP dependencies
         apm_deps = apm_package.get_apm_dependencies()
         mcp_deps = apm_package.get_mcp_dependencies()
 
-        # Determine what to install based on --only flag
-        should_install_apm = only != "mcp"
-        should_install_mcp = only != "apm"
+        # Convert --only string to InstallMode enum
+        if only is None:
+            install_mode = InstallMode.ALL
+        else:
+            install_mode = InstallMode(only)
+
+        # Determine what to install based on install mode
+        should_install_apm = install_mode != InstallMode.MCP
+        should_install_mcp = install_mode != InstallMode.APM
 
         # Show what will be installed if dry run
         if dry_run:
@@ -430,10 +453,14 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo
                 # 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, apm_diagnostics = _install_apm_dependencies(
+                install_result = _install_apm_dependencies(
                     apm_package, update, verbose, only_pkgs, force=force,
                     parallel_downloads=parallel_downloads,
                 )
+                apm_count = install_result.installed_count
+                prompt_count = install_result.prompts_integrated
+                agent_count = install_result.agents_integrated
+                apm_diagnostics = install_result.diagnostics
             except Exception as e:
                 _rich_error(f"Failed to install APM dependencies: {e}")
                 sys.exit(1)
@@ -447,7 +474,7 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo
             clear_apm_yml_cache()
 
         # Collect transitive MCP dependencies from resolved APM packages
-        apm_modules_path = Path.cwd() / "apm_modules"
+        apm_modules_path = Path.cwd() / APM_MODULES_DIR
         if should_install_mcp and apm_modules_path.exists():
             lock_path = get_lockfile_path(Path.cwd())
             transitive_mcp = MCPIntegrator.collect_transitive(apm_modules_path, lock_path, trust_transitive_mcp)
@@ -489,15 +516,15 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo
             apm_diagnostics.render_summary()
         else:
             _rich_blank_line()
-        if not only:
+        if install_mode == InstallMode.ALL:
             # Load apm.yml config for summary
             apm_config = _load_apm_config()
             _show_install_summary(
                 apm_count, prompt_count, agent_count, mcp_count, apm_config
             )
-        elif only == "apm":
+        elif install_mode == InstallMode.APM:
             _rich_success(f"Installed {apm_count} APM dependencies")
-        elif only == "mcp":
+        elif install_mode == InstallMode.MCP:
             _rich_success(f"Configured {mcp_count} MCP servers")
 
         # Hard-fail when critical security findings blocked any package.
@@ -835,7 +862,7 @@ def _install_apm_dependencies(
 
     apm_deps = apm_package.get_apm_dependencies()
     if not apm_deps:
-        return 0, 0, 0
+        return InstallResult()
 
     _rich_info(f"Installing APM dependencies ({len(apm_deps)})...")
 
@@ -850,7 +877,7 @@ def _install_apm_dependencies(
         if existing_lockfile and existing_lockfile.dependencies:
             _rich_info(f"Using apm.lock.yaml ({len(existing_lockfile.dependencies)} locked dependencies)")
 
-    apm_modules_dir = project_root / "apm_modules"
+    apm_modules_dir = project_root / APM_MODULES_DIR
     apm_modules_dir.mkdir(exist_ok=True)
 
     # Create downloader early so it can be used for transitive dependency resolution
@@ -979,7 +1006,7 @@ def _collect_descendants(node, visited=None):
 
         if not deps_to_install:
             _rich_info("No APM dependencies to install", symbol="check")
-            return 0, 0, 0
+            return InstallResult()
 
         # ------------------------------------------------------------------
         # Orphan detection: packages in lockfile no longer in the manifest.
@@ -1011,8 +1038,8 @@ def _collect_descendants(node, visited=None):
         # creating .github/ here ensures a consistent skills root and also enables
         # VSCode/Copilot integration by default (quick path to value), even for
         # projects that don't yet use .claude/.
-        github_dir = project_root / ".github"
-        claude_dir = project_root / ".claude"
+        github_dir = project_root / GITHUB_DIR
+        claude_dir = project_root / CLAUDE_DIR
         if not github_dir.exists() and not claude_dir.exists():
             github_dir.mkdir(parents=True, exist_ok=True)
             _rich_info(
@@ -1376,355 +1403,127 @@ def _collect_descendants(node, visited=None):
                     if not dep_ref.reference:
                         unpinned_count += 1
 
-                    # Still need to integrate prompts for cached packages (zero-config behavior)
-                    if integrate_vscode or integrate_claude or integrate_opencode:
-                        try:
-                            # Create PackageInfo from cached package
-                            from apm_cli.models.apm_package import (
-                                APMPackage,
-                                PackageInfo,
-                                PackageType,
-                                ResolvedReference,
-                                GitReferenceType,
-                            )
-                            from datetime import datetime
-
-                            # Load package from apm.yml in install path
-                            apm_yml_path = install_path / "apm.yml"
-                            if apm_yml_path.exists():
-                                cached_package = APMPackage.from_apm_yml(apm_yml_path)
-                                # Ensure source is set to the repo URL for sync matching
-                                if not cached_package.source:
-                                    cached_package.source = dep_ref.repo_url
-                            else:
-                                # Virtual package or no apm.yml - create minimal package
-                                cached_package = APMPackage(
-                                    name=dep_ref.repo_url.split("/")[-1],
-                                    version="unknown",
-                                    package_path=install_path,
-                                    source=dep_ref.repo_url,
-                                )
-
-                            # Create basic resolved reference for cached packages
-                            resolved_ref = ResolvedReference(
-                                original_ref=dep_ref.reference or "default",
-                                ref_type=GitReferenceType.BRANCH,
-                                resolved_commit="cached",  # Mark as cached since we don't know exact commit
-                                ref_name=dep_ref.reference or "default",
-                            )
+                    # Skip integration if not needed
+                    if not (integrate_vscode or integrate_claude or integrate_opencode):
+                        continue
 
-                            cached_package_info = PackageInfo(
-                                package=cached_package,
-                                install_path=install_path,
-                                resolved_reference=resolved_ref,
-                                installed_at=datetime.now().isoformat(),
-                                dependency_ref=dep_ref,  # Store for canonical dependency string
+                    # Integrate prompts for cached packages (zero-config behavior)
+                    try:
+                        # Create PackageInfo from cached package
+                        from apm_cli.models.apm_package import (
+                            APMPackage,
+                            PackageInfo,
+                            PackageType,
+                            ResolvedReference,
+                            GitReferenceType,
+                        )
+                        from datetime import datetime
+
+                        # Load package from apm.yml in install path
+                        apm_yml_path = install_path / APM_YML_FILENAME
+                        if apm_yml_path.exists():
+                            cached_package = APMPackage.from_apm_yml(apm_yml_path)
+                            # Ensure source is set to the repo URL for sync matching
+                            if not cached_package.source:
+                                cached_package.source = dep_ref.repo_url
+                        else:
+                            # Virtual package or no apm.yml - create minimal package
+                            cached_package = APMPackage(
+                                name=dep_ref.repo_url.split("/")[-1],
+                                version="unknown",
+                                package_path=install_path,
+                                source=dep_ref.repo_url,
                             )
 
-                            # Detect package_type from disk contents so
-                            # skill integration is not silently skipped
-                            skill_md_exists = (install_path / "SKILL.md").exists()
-                            apm_yml_exists = (install_path / "apm.yml").exists()
-                            from apm_cli.utils.helpers import find_plugin_json
-                            plugin_json_exists = find_plugin_json(install_path) is not None
-                            if plugin_json_exists and not apm_yml_exists:
-                                cached_package_info.package_type = PackageType.MARKETPLACE_PLUGIN
-                            elif skill_md_exists and apm_yml_exists:
-                                cached_package_info.package_type = PackageType.HYBRID
-                            elif skill_md_exists:
-                                cached_package_info.package_type = PackageType.CLAUDE_SKILL
-                            elif apm_yml_exists:
-                                cached_package_info.package_type = PackageType.APM_PACKAGE
-
-                            # Collect for lockfile (cached packages still need to be tracked)
-                            node = dependency_graph.dependency_tree.get_node(dep_ref.get_unique_key())
-                            depth = node.depth if node else 1
-                            resolved_by = node.parent.dependency_ref.repo_url if node and node.parent else None
-                            # Get commit SHA: callback capture > existing lockfile > explicit reference
-                            dep_key = dep_ref.get_unique_key()
-                            cached_commit = callback_downloaded.get(dep_key)
-                            if not cached_commit and existing_lockfile:
-                                locked_dep = existing_lockfile.get_dependency(dep_key)
-                                if locked_dep:
-                                    cached_commit = locked_dep.resolved_commit
-                            if not cached_commit:
-                                cached_commit = dep_ref.reference
-                            installed_packages.append((dep_ref, cached_commit, depth, resolved_by))
-                            dep_deployed: builtins.list = []  # collect deployed paths for this package
-
-                            # Track package type for lockfile
-                            if hasattr(cached_package_info, 'package_type') and cached_package_info.package_type:
-                                package_types[dep_key] = cached_package_info.package_type.value
-
-                            # Pre-deploy security gate
-                            if not _pre_deploy_security_scan(
-                                install_path, diagnostics,
-                                package_name=dep_key, force=force,
-                            ):
-                                package_deployed_files[dep_key] = []
-                                continue
-
-                            # VSCode + Claude + OpenCode integration (prompts + agents)
-                            if integrate_vscode or integrate_claude or integrate_opencode:
-                                # Integrate prompts
-                                prompt_result = (
-                                    prompt_integrator.integrate_package_prompts(
-                                        cached_package_info, project_root,
-                                        force=force, managed_files=managed_files,
-                                        diagnostics=diagnostics,
-                                    )
-                                )
-                                if prompt_result.files_integrated > 0:
-                                    total_prompts_integrated += (
-                                        prompt_result.files_integrated
-                                    )
-                                    _rich_info(
-                                        f"  └─ {prompt_result.files_integrated} prompts integrated → .github/prompts/"
-                                    )
-                                if prompt_result.files_updated > 0:
-                                    _rich_info(
-                                        f"  └─ {prompt_result.files_updated} prompts updated"
-                                    )
-                                # Track links resolved
-                                total_links_resolved += prompt_result.links_resolved
-                                for tp in prompt_result.target_paths:
-                                    dep_deployed.append(tp.relative_to(project_root).as_posix())
-
-                                # Integrate agents
-                                agent_result = (
-                                    agent_integrator.integrate_package_agents(
-                                        cached_package_info, project_root,
-                                        force=force, managed_files=managed_files,
-                                        diagnostics=diagnostics,
-                                    )
-                                )
-                                if agent_result.files_integrated > 0:
-                                    total_agents_integrated += (
-                                        agent_result.files_integrated
-                                    )
-                                    _rich_info(
-                                        f"  └─ {agent_result.files_integrated} agents integrated → .github/agents/"
-                                    )
-                                if agent_result.files_updated > 0:
-                                    _rich_info(
-                                        f"  └─ {agent_result.files_updated} agents updated"
-                                    )
-                                # Track links resolved
-                                total_links_resolved += agent_result.links_resolved
-                                for tp in agent_result.target_paths:
-                                    dep_deployed.append(tp.relative_to(project_root).as_posix())
-
-                            # Skill integration (works for VSCode, Claude, and OpenCode)
-                            # Skills go to .github/skills/ (primary) and .claude/skills/ (if .claude/ exists)
-                            if integrate_vscode or integrate_claude or integrate_opencode:
-                                skill_result = skill_integrator.integrate_package_skill(
-                                    cached_package_info, project_root,
-                                    diagnostics=diagnostics, managed_files=managed_files, force=force,
-                                )
-                                if skill_result.skill_created:
-                                    total_skills_integrated += 1
-                                    _rich_info(
-                                        f"  └─ Skill integrated → .github/skills/"
-                                    )
-                                if skill_result.sub_skills_promoted > 0:
-                                    total_sub_skills_promoted += skill_result.sub_skills_promoted
-                                    _rich_info(
-                                        f"  └─ {skill_result.sub_skills_promoted} skill(s) integrated → .github/skills/"
-                                    )
-                                for tp in skill_result.target_paths:
-                                    dep_deployed.append(tp.relative_to(project_root).as_posix())
-
-                            # Integrate instructions → .github/instructions/
-                            if integrate_vscode:
-                                instruction_result = (
-                                    instruction_integrator.integrate_package_instructions(
-                                        cached_package_info, project_root,
-                                        force=force, managed_files=managed_files,
-                                        diagnostics=diagnostics,
-                                    )
-                                )
-                                if instruction_result.files_integrated > 0:
-                                    total_instructions_integrated += (
-                                        instruction_result.files_integrated
-                                    )
-                                    _rich_info(
-                                        f"  └─ {instruction_result.files_integrated} instruction(s) integrated → .github/instructions/"
-                                    )
-                                total_links_resolved += instruction_result.links_resolved
-                                for tp in instruction_result.target_paths:
-                                    dep_deployed.append(tp.relative_to(project_root).as_posix())
-
-                            # Integrate instructions → .cursor/rules/
-                            cursor_rules_result = (
-                                instruction_integrator.integrate_package_instructions_cursor(
-                                    cached_package_info, project_root,
-                                    force=force, managed_files=managed_files,
-                                    diagnostics=diagnostics,
-                                )
-                            )
-                            if cursor_rules_result.files_integrated > 0:
-                                total_instructions_integrated += (
-                                    cursor_rules_result.files_integrated
-                                )
-                                _rich_info(
-                                    f"  └─ {cursor_rules_result.files_integrated} rule(s) integrated → .cursor/rules/"
-                                )
-                            total_links_resolved += cursor_rules_result.links_resolved
-                            for tp in cursor_rules_result.target_paths:
-                                dep_deployed.append(tp.relative_to(project_root).as_posix())
-
-                            # Claude-specific integration (agents + commands)
-                            if integrate_claude:
-                                # Integrate agents to .claude/agents/
-                                claude_agent_result = (
-                                    agent_integrator.integrate_package_agents_claude(
-                                        cached_package_info, project_root,
-                                        force=force, managed_files=managed_files,
-                                        diagnostics=diagnostics,
-                                    )
-                                )
-                                if claude_agent_result.files_integrated > 0:
-                                    total_agents_integrated += (
-                                        claude_agent_result.files_integrated
-                                    )
-                                    _rich_info(
-                                        f"  └─ {claude_agent_result.files_integrated} agents integrated → .claude/agents/"
-                                    )
-                                total_links_resolved += claude_agent_result.links_resolved
-                                for tp in claude_agent_result.target_paths:
-                                    dep_deployed.append(tp.relative_to(project_root).as_posix())
-
-                            # Cursor agents (.cursor/agents/) — opt-in
-                            cursor_agent_result = (
-                                agent_integrator.integrate_package_agents_cursor(
-                                    cached_package_info, project_root,
-                                    force=force, managed_files=managed_files,
-                                    diagnostics=diagnostics,
-                                )
-                            )
-                            if cursor_agent_result.files_integrated > 0:
-                                total_agents_integrated += (
-                                    cursor_agent_result.files_integrated
-                                )
-                                _rich_info(
-                                    f"  └─ {cursor_agent_result.files_integrated} agents integrated → .cursor/agents/"
-                                )
-                            total_links_resolved += cursor_agent_result.links_resolved
-                            for tp in cursor_agent_result.target_paths:
-                                dep_deployed.append(tp.relative_to(project_root).as_posix())
-
-                            # OpenCode agents (.opencode/agents/) — opt-in
-                            opencode_agent_result = (
-                                agent_integrator.integrate_package_agents_opencode(
-                                    cached_package_info, project_root,
-                                    force=force, managed_files=managed_files,
-                                    diagnostics=diagnostics,
-                                )
-                            )
-                            if opencode_agent_result.files_integrated > 0:
-                                total_agents_integrated += (
-                                    opencode_agent_result.files_integrated
-                                )
-                                _rich_info(
-                                    f"  └─ {opencode_agent_result.files_integrated} agents integrated → .opencode/agents/"
-                                )
-                            total_links_resolved += opencode_agent_result.links_resolved
-                            for tp in opencode_agent_result.target_paths:
-                                dep_deployed.append(tp.relative_to(project_root).as_posix())
-
-                            # Claude-specific integration (commands)
-                            if integrate_claude:
-                                command_result = (
-                                    command_integrator.integrate_package_commands(
-                                        cached_package_info, project_root,
-                                        force=force, managed_files=managed_files,
-                                        diagnostics=diagnostics,
-                                    )
-                                )
-                                if command_result.files_integrated > 0:
-                                    total_commands_integrated += (
-                                        command_result.files_integrated
-                                    )
-                                    _rich_info(
-                                        f"  └─ {command_result.files_integrated} commands integrated → .claude/commands/"
-                                    )
-                                if command_result.files_updated > 0:
-                                    _rich_info(
-                                        f"  └─ {command_result.files_updated} commands updated"
-                                    )
-                                total_links_resolved += command_result.links_resolved
-                                for tp in command_result.target_paths:
-                                    dep_deployed.append(tp.relative_to(project_root).as_posix())
-
-                            # OpenCode commands (.opencode/commands/) — opt-in
-                            opencode_command_result = (
-                                command_integrator.integrate_package_commands_opencode(
-                                    cached_package_info, project_root,
-                                    force=force, managed_files=managed_files,
-                                    diagnostics=diagnostics,
-                                )
-                            )
-                            if opencode_command_result.files_integrated > 0:
-                                total_commands_integrated += (
-                                    opencode_command_result.files_integrated
-                                )
-                                _rich_info(
-                                    f"  └─ {opencode_command_result.files_integrated} commands integrated → .opencode/commands/"
-                                )
-                            total_links_resolved += opencode_command_result.links_resolved
-                            for tp in opencode_command_result.target_paths:
-                                dep_deployed.append(tp.relative_to(project_root).as_posix())
-
-                            # Hook integration (target-aware)
-                            if integrate_vscode:
-                                hook_result = hook_integrator.integrate_package_hooks(
-                                    cached_package_info, project_root,
-                                    force=force, managed_files=managed_files,
-                                    diagnostics=diagnostics,
-                                )
-                                if hook_result.hooks_integrated > 0:
-                                    total_hooks_integrated += hook_result.hooks_integrated
-                                    _rich_info(
-                                        f"  └─ {hook_result.hooks_integrated} hook(s) integrated → .github/hooks/"
-                                    )
-                                for tp in hook_result.target_paths:
-                                    dep_deployed.append(tp.relative_to(project_root).as_posix())
-                            if integrate_claude:
-                                hook_result_claude = hook_integrator.integrate_package_hooks_claude(
-                                    cached_package_info, project_root,
-                                    force=force, managed_files=managed_files,
-                                    diagnostics=diagnostics,
-                                )
-                                if hook_result_claude.hooks_integrated > 0:
-                                    total_hooks_integrated += hook_result_claude.hooks_integrated
-                                    _rich_info(
-                                        f"  └─ {hook_result_claude.hooks_integrated} hook(s) integrated → .claude/settings.json"
-                                    )
-                                for tp in hook_result_claude.target_paths:
-                                    dep_deployed.append(tp.relative_to(project_root).as_posix())
-
-                            # Cursor hooks (.cursor/hooks.json)
-                            hook_result_cursor = hook_integrator.integrate_package_hooks_cursor(
-                                cached_package_info, project_root,
-                                force=force, managed_files=managed_files,
-                                diagnostics=diagnostics,
-                            )
-                            if hook_result_cursor.hooks_integrated > 0:
-                                total_hooks_integrated += hook_result_cursor.hooks_integrated
-                                _rich_info(
-                                    f"  └─ {hook_result_cursor.hooks_integrated} hook(s) integrated → .cursor/hooks.json"
-                                )
-                            for tp in hook_result_cursor.target_paths:
-                                dep_deployed.append(tp.relative_to(project_root).as_posix())
-
-                            # Record deployed files for this package
-                            package_deployed_files[dep_key] = dep_deployed
-                        except Exception as e:
-                            # Don't fail installation if integration fails
-                            diagnostics.error(
-                                f"Failed to integrate primitives from cached package: {e}",
-                                package=dep_key,
-                            )
+                        # Create basic resolved reference for cached packages
+                        resolved_ref = ResolvedReference(
+                            original_ref=dep_ref.reference or "default",
+                            ref_type=GitReferenceType.BRANCH,
+                            resolved_commit="cached",  # Mark as cached since we don't know exact commit
+                            ref_name=dep_ref.reference or "default",
+                        )
+
+                        cached_package_info = PackageInfo(
+                            package=cached_package,
+                            install_path=install_path,
+                            resolved_reference=resolved_ref,
+                            installed_at=datetime.now().isoformat(),
+                            dependency_ref=dep_ref,  # Store for canonical dependency string
+                        )
+
+                        # Detect package_type from disk contents so
+                        # skill integration is not silently skipped
+                        skill_md_exists = (install_path / SKILL_MD_FILENAME).exists()
+                        apm_yml_exists = (install_path / APM_YML_FILENAME).exists()
+                        from apm_cli.utils.helpers import find_plugin_json
+                        plugin_json_exists = find_plugin_json(install_path) is not None
+                        if plugin_json_exists and not apm_yml_exists:
+                            cached_package_info.package_type = PackageType.MARKETPLACE_PLUGIN
+                        elif skill_md_exists and apm_yml_exists:
+                            cached_package_info.package_type = PackageType.HYBRID
+                        elif skill_md_exists:
+                            cached_package_info.package_type = PackageType.CLAUDE_SKILL
+                        elif apm_yml_exists:
+                            cached_package_info.package_type = PackageType.APM_PACKAGE
+
+                        # Collect for lockfile (cached packages still need to be tracked)
+                        node = dependency_graph.dependency_tree.get_node(dep_ref.get_unique_key())
+                        depth = node.depth if node else 1
+                        resolved_by = node.parent.dependency_ref.repo_url if node and node.parent else None
+                        # Get commit SHA: callback capture > existing lockfile > explicit reference
+                        dep_key = dep_ref.get_unique_key()
+                        cached_commit = callback_downloaded.get(dep_key)
+                        if not cached_commit and existing_lockfile:
+                            locked_dep = existing_lockfile.get_dependency(dep_key)
+                            if locked_dep:
+                                cached_commit = locked_dep.resolved_commit
+                        if not cached_commit:
+                            cached_commit = dep_ref.reference
+                        installed_packages.append((dep_ref, cached_commit, depth, resolved_by))
+
+                        # Track package type for lockfile
+                        if hasattr(cached_package_info, 'package_type') and cached_package_info.package_type:
+                            package_types[dep_key] = cached_package_info.package_type.value
+
+                        # Pre-deploy security gate
+                        if not _pre_deploy_security_scan(
+                            install_path, diagnostics,
+                            package_name=dep_key, force=force,
+                        ):
+                            package_deployed_files[dep_key] = []
+                            continue
+
+                        int_result = _integrate_package_primitives(
+                            cached_package_info, project_root,
+                            integrate_vscode=integrate_vscode,
+                            integrate_claude=integrate_claude,
+                            integrate_opencode=integrate_opencode,
+                            prompt_integrator=prompt_integrator,
+                            agent_integrator=agent_integrator,
+                            skill_integrator=skill_integrator,
+                            instruction_integrator=instruction_integrator,
+                            command_integrator=command_integrator,
+                            hook_integrator=hook_integrator,
+                            force=force,
+                            managed_files=managed_files,
+                            diagnostics=diagnostics,
+                            package_name=dep_key,
+                        )
+                        total_prompts_integrated += int_result["prompts"]
+                        total_agents_integrated += int_result["agents"]
+                        total_skills_integrated += int_result["skills"]
+                        total_sub_skills_promoted += int_result["sub_skills"]
+                        total_instructions_integrated += int_result["instructions"]
+                        total_commands_integrated += int_result["commands"]
+                        total_hooks_integrated += int_result["hooks"]
+                        total_links_resolved += int_result["links_resolved"]
+                        dep_deployed = int_result["deployed_files"]
+                        package_deployed_files[dep_key] = dep_deployed
+                    except Exception as e:
+                        diagnostics.error(
+                            f"Failed to integrate primitives from cached package: {e}",
+                            package=dep_key,
+                        )
 
                     continue
 
@@ -1788,7 +1587,6 @@ def _collect_descendants(node, visited=None):
                     depth = node.depth if node else 1
                     resolved_by = node.parent.dependency_ref.repo_url if node and node.parent else None
                     installed_packages.append((dep_ref, resolved_commit, depth, resolved_by))
-                    dep_deployed_fresh: builtins.list = []  # collect deployed paths for this package
 
                     # Track package type for lockfile
                     if hasattr(package_info, 'package_type') and package_info.package_type:
@@ -1825,259 +1623,31 @@ def _collect_descendants(node, visited=None):
 
                     if integrate_vscode or integrate_claude or integrate_opencode:
                         try:
-                            # Integrate prompts + agents (dual-target: .github/ + .claude/)
-                            # Integrate prompts
-                            prompt_result = (
-                                prompt_integrator.integrate_package_prompts(
-                                    package_info, project_root,
-                                    force=force, managed_files=managed_files,
-                                    diagnostics=diagnostics,
-                                )
-                            )
-                            if prompt_result.files_integrated > 0:
-                                total_prompts_integrated += (
-                                    prompt_result.files_integrated
-                                )
-                                _rich_info(
-                                    f"  └─ {prompt_result.files_integrated} prompts integrated → .github/prompts/"
-                                )
-                            if prompt_result.files_updated > 0:
-                                _rich_info(
-                                    f"  └─ {prompt_result.files_updated} prompts updated"
-                                )
-                            # Track links resolved
-                            total_links_resolved += prompt_result.links_resolved
-                            for tp in prompt_result.target_paths:
-                                dep_deployed_fresh.append(tp.relative_to(project_root).as_posix())
-
-                            # Integrate agents
-                            agent_result = (
-                                agent_integrator.integrate_package_agents(
-                                    package_info, project_root,
-                                    force=force, managed_files=managed_files,
-                                    diagnostics=diagnostics,
-                                )
-                            )
-                            if agent_result.files_integrated > 0:
-                                total_agents_integrated += (
-                                    agent_result.files_integrated
-                                )
-                                _rich_info(
-                                    f"  └─ {agent_result.files_integrated} agents integrated → .github/agents/"
-                                )
-                            if agent_result.files_updated > 0:
-                                _rich_info(
-                                    f"  └─ {agent_result.files_updated} agents updated"
-                                )
-                            # Track links resolved
-                            total_links_resolved += agent_result.links_resolved
-                            for tp in agent_result.target_paths:
-                                dep_deployed_fresh.append(tp.relative_to(project_root).as_posix())
-
-                            # Skill integration (works for VSCode, Claude, and OpenCode)
-                            # Skills go to .github/skills/ (primary) and .claude/skills/ (if .claude/ exists)
-                            if integrate_vscode or integrate_claude or integrate_opencode:
-                                skill_result = skill_integrator.integrate_package_skill(
-                                    package_info, project_root,
-                                    diagnostics=diagnostics, managed_files=managed_files, force=force,
-                                )
-                                if skill_result.skill_created:
-                                    total_skills_integrated += 1
-                                    _rich_info(
-                                        f"  └─ Skill integrated → .github/skills/"
-                                    )
-                                if skill_result.sub_skills_promoted > 0:
-                                    total_sub_skills_promoted += skill_result.sub_skills_promoted
-                                    _rich_info(
-                                        f"  └─ {skill_result.sub_skills_promoted} skill(s) integrated → .github/skills/"
-                                    )
-                                for tp in skill_result.target_paths:
-                                    dep_deployed_fresh.append(tp.relative_to(project_root).as_posix())
-
-                            # Integrate instructions → .github/instructions/
-                            if integrate_vscode:
-                                instruction_result = (
-                                    instruction_integrator.integrate_package_instructions(
-                                        package_info, project_root,
-                                        force=force, managed_files=managed_files,
-                                        diagnostics=diagnostics,
-                                    )
-                                )
-                                if instruction_result.files_integrated > 0:
-                                    total_instructions_integrated += (
-                                        instruction_result.files_integrated
-                                    )
-                                    _rich_info(
-                                        f"  └─ {instruction_result.files_integrated} instruction(s) integrated → .github/instructions/"
-                                    )
-                                total_links_resolved += instruction_result.links_resolved
-                                for tp in instruction_result.target_paths:
-                                    dep_deployed_fresh.append(tp.relative_to(project_root).as_posix())
-
-                            # Integrate instructions → .cursor/rules/
-                            cursor_rules_result = (
-                                instruction_integrator.integrate_package_instructions_cursor(
-                                    package_info, project_root,
-                                    force=force, managed_files=managed_files,
-                                    diagnostics=diagnostics,
-                                )
-                            )
-                            if cursor_rules_result.files_integrated > 0:
-                                total_instructions_integrated += (
-                                    cursor_rules_result.files_integrated
-                                )
-                                _rich_info(
-                                    f"  └─ {cursor_rules_result.files_integrated} rule(s) integrated → .cursor/rules/"
-                                )
-                            total_links_resolved += cursor_rules_result.links_resolved
-                            for tp in cursor_rules_result.target_paths:
-                                dep_deployed_fresh.append(tp.relative_to(project_root).as_posix())
-
-                            # Claude-specific integration (agents + commands)
-                            if integrate_claude:
-                                # Integrate agents to .claude/agents/
-                                claude_agent_result = (
-                                    agent_integrator.integrate_package_agents_claude(
-                                        package_info, project_root,
-                                        force=force, managed_files=managed_files,
-                                        diagnostics=diagnostics,
-                                    )
-                                )
-                                if claude_agent_result.files_integrated > 0:
-                                    total_agents_integrated += (
-                                        claude_agent_result.files_integrated
-                                    )
-                                    _rich_info(
-                                        f"  └─ {claude_agent_result.files_integrated} agents integrated → .claude/agents/"
-                                    )
-                                total_links_resolved += claude_agent_result.links_resolved
-                                for tp in claude_agent_result.target_paths:
-                                    dep_deployed_fresh.append(tp.relative_to(project_root).as_posix())
-
-                            # Cursor agents (.cursor/agents/) — opt-in
-                            cursor_agent_result = (
-                                agent_integrator.integrate_package_agents_cursor(
-                                    package_info, project_root,
-                                    force=force, managed_files=managed_files,
-                                    diagnostics=diagnostics,
-                                )
-                            )
-                            if cursor_agent_result.files_integrated > 0:
-                                total_agents_integrated += (
-                                    cursor_agent_result.files_integrated
-                                )
-                                _rich_info(
-                                    f"  └─ {cursor_agent_result.files_integrated} agents integrated → .cursor/agents/"
-                                )
-                            total_links_resolved += cursor_agent_result.links_resolved
-                            for tp in cursor_agent_result.target_paths:
-                                dep_deployed_fresh.append(tp.relative_to(project_root).as_posix())
-
-                            # OpenCode agents (.opencode/agents/) — opt-in
-                            opencode_agent_result = (
-                                agent_integrator.integrate_package_agents_opencode(
-                                    package_info, project_root,
-                                    force=force, managed_files=managed_files,
-                                    diagnostics=diagnostics,
-                                )
-                            )
-                            if opencode_agent_result.files_integrated > 0:
-                                total_agents_integrated += (
-                                    opencode_agent_result.files_integrated
-                                )
-                                _rich_info(
-                                    f"  └─ {opencode_agent_result.files_integrated} agents integrated → .opencode/agents/"
-                                )
-                            total_links_resolved += opencode_agent_result.links_resolved
-                            for tp in opencode_agent_result.target_paths:
-                                dep_deployed_fresh.append(tp.relative_to(project_root).as_posix())
-
-                            # Claude-specific integration (commands)
-                            if integrate_claude:
-                                command_result = (
-                                    command_integrator.integrate_package_commands(
-                                        package_info, project_root,
-                                        force=force, managed_files=managed_files,
-                                        diagnostics=diagnostics,
-                                    )
-                                )
-                                if command_result.files_integrated > 0:
-                                    total_commands_integrated += (
-                                        command_result.files_integrated
-                                    )
-                                    _rich_info(
-                                        f"  └─ {command_result.files_integrated} commands integrated → .claude/commands/"
-                                    )
-                                if command_result.files_updated > 0:
-                                    _rich_info(
-                                        f"  └─ {command_result.files_updated} commands updated"
-                                    )
-                                total_links_resolved += command_result.links_resolved
-                                for tp in command_result.target_paths:
-                                    dep_deployed_fresh.append(tp.relative_to(project_root).as_posix())
-
-                            # OpenCode commands (.opencode/commands/) — opt-in
-                            opencode_command_result = (
-                                command_integrator.integrate_package_commands_opencode(
-                                    package_info, project_root,
-                                    force=force, managed_files=managed_files,
-                                    diagnostics=diagnostics,
-                                )
-                            )
-                            if opencode_command_result.files_integrated > 0:
-                                total_commands_integrated += (
-                                    opencode_command_result.files_integrated
-                                )
-                                _rich_info(
-                                    f"  └─ {opencode_command_result.files_integrated} commands integrated → .opencode/commands/"
-                                )
-                            total_links_resolved += opencode_command_result.links_resolved
-                            for tp in opencode_command_result.target_paths:
-                                dep_deployed_fresh.append(tp.relative_to(project_root).as_posix())
-
-                            # Hook integration (target-aware)
-                            if integrate_vscode:
-                                hook_result = hook_integrator.integrate_package_hooks(
-                                    package_info, project_root,
-                                    force=force, managed_files=managed_files,
-                                    diagnostics=diagnostics,
-                                )
-                                if hook_result.hooks_integrated > 0:
-                                    total_hooks_integrated += hook_result.hooks_integrated
-                                    _rich_info(
-                                        f"  └─ {hook_result.hooks_integrated} hook(s) integrated → .github/hooks/"
-                                    )
-                                for tp in hook_result.target_paths:
-                                    dep_deployed_fresh.append(tp.relative_to(project_root).as_posix())
-                            if integrate_claude:
-                                hook_result_claude = hook_integrator.integrate_package_hooks_claude(
-                                    package_info, project_root,
-                                    force=force, managed_files=managed_files,
-                                    diagnostics=diagnostics,
-                                )
-                                if hook_result_claude.hooks_integrated > 0:
-                                    total_hooks_integrated += hook_result_claude.hooks_integrated
-                                    _rich_info(
-                                        f"  └─ {hook_result_claude.hooks_integrated} hook(s) integrated → .claude/settings.json"
-                                    )
-                                for tp in hook_result_claude.target_paths:
-                                    dep_deployed_fresh.append(tp.relative_to(project_root).as_posix())
-
-                            # Cursor hooks (.cursor/hooks.json)
-                            hook_result_cursor = hook_integrator.integrate_package_hooks_cursor(
+                            int_result = _integrate_package_primitives(
                                 package_info, project_root,
-                                force=force, managed_files=managed_files,
+                                integrate_vscode=integrate_vscode,
+                                integrate_claude=integrate_claude,
+                                integrate_opencode=integrate_opencode,
+                                prompt_integrator=prompt_integrator,
+                                agent_integrator=agent_integrator,
+                                skill_integrator=skill_integrator,
+                                instruction_integrator=instruction_integrator,
+                                command_integrator=command_integrator,
+                                hook_integrator=hook_integrator,
+                                force=force,
+                                managed_files=managed_files,
                                 diagnostics=diagnostics,
+                                package_name=dep_ref.get_unique_key(),
                             )
-                            if hook_result_cursor.hooks_integrated > 0:
-                                total_hooks_integrated += hook_result_cursor.hooks_integrated
-                                _rich_info(
-                                    f"  └─ {hook_result_cursor.hooks_integrated} hook(s) integrated → .cursor/hooks.json"
-                                )
-                            for tp in hook_result_cursor.target_paths:
-                                dep_deployed_fresh.append(tp.relative_to(project_root).as_posix())
-
-                            # Record deployed files for this package
+                            total_prompts_integrated += int_result["prompts"]
+                            total_agents_integrated += int_result["agents"]
+                            total_skills_integrated += int_result["skills"]
+                            total_sub_skills_promoted += int_result["sub_skills"]
+                            total_instructions_integrated += int_result["instructions"]
+                            total_commands_integrated += int_result["commands"]
+                            total_hooks_integrated += int_result["hooks"]
+                            total_links_resolved += int_result["links_resolved"]
+                            dep_deployed_fresh = int_result["deployed_files"]
                             package_deployed_files[dep_ref.get_unique_key()] = dep_deployed_fresh
                         except Exception as e:
                             # Don't fail installation if integration fails
@@ -2214,7 +1784,7 @@ def _collect_descendants(node, visited=None):
                 f"-- pin with #tag or #sha to prevent drift"
             )
 
-        return installed_count, total_prompts_integrated, total_agents_integrated, diagnostics
+        return InstallResult(installed_count, total_prompts_integrated, total_agents_integrated, diagnostics)
 
     except Exception as e:
         raise RuntimeError(f"Failed to resolve APM dependencies: {e}")
diff --git a/src/apm_cli/commands/prune.py b/src/apm_cli/commands/prune.py
index 816e45fcd..96a1e3fa1 100644
--- a/src/apm_cli/commands/prune.py
+++ b/src/apm_cli/commands/prune.py
@@ -6,6 +6,7 @@
 
 import click
 
+from ..constants import APM_LOCK_FILENAME, APM_MODULES_DIR, APM_YML_FILENAME
 from ..utils.console import _rich_error, _rich_info, _rich_success, _rich_warning
 from ..utils.path_security import PathTraversalError, safe_rmtree
 from ._helpers import _build_expected_install_paths, _scan_installed_packages
@@ -32,12 +33,12 @@ def prune(ctx, dry_run):
     """
     try:
         # Check if apm.yml exists
-        if not Path("apm.yml").exists():
+        if not Path(APM_YML_FILENAME).exists():
             _rich_error("No apm.yml found. Run 'apm init' first.")
             sys.exit(1)
 
         # Check if apm_modules exists
-        apm_modules_dir = Path("apm_modules")
+        apm_modules_dir = Path(APM_MODULES_DIR)
         if not apm_modules_dir.exists():
             _rich_info("No apm_modules/ directory found. Nothing to prune.")
             return
@@ -46,12 +47,12 @@ def prune(ctx, dry_run):
 
         # Build expected vs installed using shared helpers
         try:
-            apm_package = APMPackage.from_apm_yml(Path("apm.yml"))
+            apm_package = APMPackage.from_apm_yml(Path(APM_YML_FILENAME))
             declared_deps = apm_package.get_apm_dependencies()
             lockfile = LockFile.read(get_lockfile_path(Path.cwd()))
             expected_installed = _build_expected_install_paths(declared_deps, lockfile, apm_modules_dir)
         except Exception as e:
-            _rich_error(f"Failed to parse apm.yml: {e}")
+            _rich_error(f"Failed to parse {APM_YML_FILENAME}: {e}")
             sys.exit(1)
 
         installed_packages = _scan_installed_packages(apm_modules_dir)
@@ -103,19 +104,23 @@ def prune(ctx, dry_run):
                 deleted_targets: list = []
                 for dep_key in pruned_keys:
                     dep = lockfile.get_dependency(dep_key)
-                    if dep and dep.deployed_files:
-                        for rel_path in dep.deployed_files:
-                            if not BaseIntegrator.validate_deploy_path(rel_path, project_root):
-                                continue
-                            target = project_root / rel_path
-                            if target.is_file():
-                                target.unlink()
-                                deployed_cleaned += 1
-                                deleted_targets.append(target)
-                            elif target.is_dir():
-                                shutil.rmtree(target)
-                                deployed_cleaned += 1
-                                deleted_targets.append(target)
+                    if not dep or not dep.deployed_files:
+                        # No deployed files to clean — just remove lockfile entry
+                        if dep_key in lockfile.dependencies:
+                            del lockfile.dependencies[dep_key]
+                        continue
+                    for rel_path in dep.deployed_files:
+                        if not BaseIntegrator.validate_deploy_path(rel_path, project_root):
+                            continue
+                        target = project_root / rel_path
+                        if target.is_file():
+                            target.unlink()
+                            deployed_cleaned += 1
+                            deleted_targets.append(target)
+                        elif target.is_dir():
+                            shutil.rmtree(target)
+                            deployed_cleaned += 1
+                            deleted_targets.append(target)
                     # Remove from lockfile
                     if dep_key in lockfile.dependencies:
                         del lockfile.dependencies[dep_key]
diff --git a/src/apm_cli/commands/uninstall.py b/src/apm_cli/commands/uninstall.py
deleted file mode 100644
index 41b9208f9..000000000
--- a/src/apm_cli/commands/uninstall.py
+++ /dev/null
@@ -1,591 +0,0 @@
-"""APM uninstall command."""
-
-import builtins
-import shutil
-import sys
-from pathlib import Path
-
-import click
-
-from ..utils.console import _rich_error, _rich_info, _rich_success, _rich_warning
-from ..utils.path_security import PathTraversalError, safe_rmtree
-
-# APM Dependencies
-try:
-    from ..deps.lockfile import LockFile
-    from ..models.apm_package import APMPackage, DependencyReference
-    from ..integration.mcp_integrator import MCPIntegrator
-
-    APM_DEPS_AVAILABLE = True
-except ImportError:
-    APM_DEPS_AVAILABLE = False
-
-
-@click.command(help="Remove APM packages, their integrated files, and apm.yml entries")
-@click.argument("packages", nargs=-1, required=True)
-@click.option(
-    "--dry-run", is_flag=True, help="Show what would be removed without removing"
-)
-@click.pass_context
-def uninstall(ctx, packages, dry_run):
-    """Remove APM packages from apm.yml and apm_modules (like npm uninstall).
-
-    This command removes packages from both the apm.yml dependencies list
-    and the apm_modules/ directory. It's the opposite of 'apm install <package>'.
-
-    Examples:
-        apm uninstall acme/my-package                # Remove one package
-        apm uninstall org/pkg1 org/pkg2              # Remove multiple packages
-        apm uninstall acme/my-package --dry-run      # Show what would be removed
-    """
-    try:
-        # Check if apm.yml exists
-        if not Path("apm.yml").exists():
-            _rich_error("No apm.yml found. Run 'apm init' first.")
-            sys.exit(1)
-
-        if not packages:
-            _rich_error("No packages specified. Specify packages to uninstall.")
-            sys.exit(1)
-
-        _rich_info(f"Uninstalling {len(packages)} package(s)...")
-
-        # Read current apm.yml
-        import yaml
-
-        apm_yml_path = Path("apm.yml")
-        try:
-            with open(apm_yml_path, "r") as f:
-                data = yaml.safe_load(f) or {}
-        except Exception as e:
-            _rich_error(f"Failed to read apm.yml: {e}")
-            sys.exit(1)
-
-        # Ensure dependencies structure exists
-        if "dependencies" not in data:
-            data["dependencies"] = {}
-        if "apm" not in data["dependencies"]:
-            data["dependencies"]["apm"] = []
-
-        current_deps = data["dependencies"]["apm"] or []
-        packages_to_remove = []
-        packages_not_found = []
-
-        def _parse_dependency_entry(dep_entry):
-            if isinstance(dep_entry, DependencyReference):
-                return dep_entry
-            if isinstance(dep_entry, str):
-                return DependencyReference.parse(dep_entry)
-            if isinstance(dep_entry, dict):
-                return DependencyReference.parse_from_dict(dep_entry)
-            raise ValueError(f"Unsupported dependency entry type: {type(dep_entry).__name__}")
-
-        # Validate which packages can be removed
-        for package in packages:
-            # Validate package format (should be owner/repo or a git URL)
-            if "/" not in package:
-                _rich_error(
-                    f"Invalid package format: {package}. Use 'owner/repo' format."
-                )
-                continue
-
-            # Match by identity: parse the user input and each apm.yml entry,
-            # compare using get_identity() which normalizes host differences.
-            matched_dep = None
-            try:
-                pkg_ref = DependencyReference.parse(package)
-                pkg_identity = pkg_ref.get_identity()
-            except Exception:
-                pkg_identity = package
-
-            for dep_entry in current_deps:
-                try:
-                    dep_ref = _parse_dependency_entry(dep_entry)
-                    if dep_ref.get_identity() == pkg_identity:
-                        matched_dep = dep_entry  # preserve original entry for removal
-                        break
-                except (ValueError, TypeError, AttributeError, KeyError):
-                    # Fallback: exact string match
-                    dep_str = dep_entry if isinstance(dep_entry, str) else str(dep_entry)
-                    if dep_str == package:
-                        matched_dep = dep_entry
-                        break
-                    pass
-
-            if matched_dep is not None:
-                packages_to_remove.append(matched_dep)
-                _rich_info(f"+ {package} - found in apm.yml")
-            else:
-                packages_not_found.append(package)
-                _rich_warning(f"x {package} - not found in apm.yml")
-
-        if not packages_to_remove:
-            _rich_warning("No packages found in apm.yml to remove")
-            return
-
-        if dry_run:
-            _rich_info(f"Dry run: Would remove {len(packages_to_remove)} package(s):")
-            apm_modules_dir = Path("apm_modules")
-            for pkg in packages_to_remove:
-                _rich_info(f"  - {pkg} from apm.yml")
-                # Check if package exists in apm_modules
-                try:
-                    dep_ref = _parse_dependency_entry(pkg)
-                    package_path = dep_ref.get_install_path(apm_modules_dir)
-                except (ValueError, TypeError, AttributeError, KeyError):
-                    pkg_str = pkg if isinstance(pkg, str) else str(pkg)
-                    package_path = apm_modules_dir / pkg_str.split("/")[-1]
-                if apm_modules_dir.exists() and package_path.exists():
-                    _rich_info(f"  - {pkg} from apm_modules/")
-
-            # Show transitive deps that would be removed
-            from ..deps.lockfile import LockFile, get_lockfile_path
-            lockfile_path = get_lockfile_path(Path("."))
-            lockfile = LockFile.read(lockfile_path)
-            if lockfile:
-                removed_repo_urls = builtins.set()
-                for pkg in packages_to_remove:
-                    try:
-                        ref = _parse_dependency_entry(pkg)
-                        removed_repo_urls.add(ref.repo_url)
-                    except (ValueError, TypeError, AttributeError, KeyError):
-                        removed_repo_urls.add(pkg)
-                # Find transitive orphans
-                queue = builtins.list(removed_repo_urls)
-                potential_orphans = builtins.set()
-                while queue:
-                    parent_url = queue.pop()
-                    for dep in lockfile.get_all_dependencies():
-                        key = dep.get_unique_key()
-                        if key in potential_orphans:
-                            continue
-                        if dep.resolved_by and dep.resolved_by == parent_url:
-                            potential_orphans.add(key)
-                            queue.append(dep.repo_url)
-                if potential_orphans:
-                    _rich_info(f"  Transitive dependencies that would be removed:")
-                    for orphan_key in sorted(potential_orphans):
-                        _rich_info(f"    - {orphan_key}")
-
-            _rich_success("Dry run complete - no changes made")
-            return
-
-        # Remove packages from apm.yml
-        for package in packages_to_remove:
-            current_deps.remove(package)
-            _rich_info(f"Removed {package} from apm.yml")
-
-        # Update dependencies in apm.yml
-        data["dependencies"]["apm"] = current_deps
-
-        # Write back to apm.yml
-        try:
-            with open(apm_yml_path, "w") as f:
-                yaml.safe_dump(data, f, default_flow_style=False, sort_keys=False)
-            _rich_success(
-                f"Updated apm.yml (removed {len(packages_to_remove)} package(s))"
-            )
-        except Exception as e:
-            _rich_error(f"Failed to write apm.yml: {e}")
-            sys.exit(1)
-
-        # Remove packages from apm_modules/
-        apm_modules_dir = Path("apm_modules")
-        removed_from_modules = 0
-
-        # npm-style transitive dep cleanup: use lockfile to find orphaned transitive deps
-        from ..deps.lockfile import LockFile, get_lockfile_path
-        lockfile_path = get_lockfile_path(Path("."))
-        lockfile = LockFile.read(lockfile_path)
-
-        # Capture MCP servers from lockfile *before* it is mutated/deleted so
-        # that stale-MCP cleanup can compute the diff even when all deps are removed.
-        _pre_uninstall_mcp_servers = builtins.set(lockfile.mcp_servers) if lockfile else builtins.set()
-
-        if apm_modules_dir.exists():
-            deleted_pkg_paths: list = []
-            for package in packages_to_remove:
-                # Parse package into DependencyReference to get canonical install path
-                # This correctly handles virtual packages (owner/repo-packagename) vs
-                # regular packages (owner/repo) and ADO paths (org/project/repo)
-                try:
-                    dep_ref = _parse_dependency_entry(package)
-                    package_path = dep_ref.get_install_path(apm_modules_dir)
-                except (PathTraversalError,) as e:
-                    _rich_error(f"x Refusing to remove {package}: {e}")
-                    continue
-                except (ValueError, TypeError, AttributeError, KeyError):
-                    # Fallback for invalid format: use raw path segments
-                    package_str = package if isinstance(package, str) else str(package)
-                    repo_parts = package_str.split("/")
-                    if len(repo_parts) >= 2:
-                        package_path = apm_modules_dir.joinpath(*repo_parts)
-                    else:
-                        package_path = apm_modules_dir / package_str
-
-                if package_path.exists():
-                    try:
-                        safe_rmtree(package_path, apm_modules_dir)
-                        _rich_info(f"+ Removed {package} from apm_modules/")
-                        removed_from_modules += 1
-                        deleted_pkg_paths.append(package_path)
-                    except Exception as e:
-                        _rich_error(
-                            f"x Failed to remove {package} from apm_modules/: {e}"
-                        )
-                else:
-                    _rich_warning(f"Package {package} not found in apm_modules/")
-
-            # Batch parent cleanup  -- single bottom-up pass
-            from ..integration.base_integrator import BaseIntegrator as _BI2
-            _BI2.cleanup_empty_parents(deleted_pkg_paths, stop_at=apm_modules_dir)
-
-        # npm-style transitive dependency cleanup: remove orphaned transitive deps
-        # After removing the direct packages, check if they had transitive deps that
-        # are no longer needed by any remaining package.
-        if lockfile and apm_modules_dir.exists():
-            # Collect the repo_urls of removed packages
-            removed_repo_urls = builtins.set()
-            for pkg in packages_to_remove:
-                try:
-                    ref = _parse_dependency_entry(pkg)
-                    removed_repo_urls.add(ref.repo_url)
-                except (ValueError, TypeError, AttributeError, KeyError):
-                    removed_repo_urls.add(pkg)
-
-            # Find all transitive deps resolved_by any removed package (recursive)
-            def _find_transitive_orphans(lockfile, removed_urls):
-                """Recursively find all transitive deps that are no longer needed."""
-                orphans = builtins.set()
-                queue = builtins.list(removed_urls)
-                while queue:
-                    parent_url = queue.pop()
-                    for dep in lockfile.get_all_dependencies():
-                        key = dep.get_unique_key()
-                        if key in orphans:
-                            continue
-                        if dep.resolved_by and dep.resolved_by == parent_url:
-                            orphans.add(key)
-                            # This orphan's own transitives are also orphaned
-                            queue.append(dep.repo_url)
-                return orphans
-
-            potential_orphans = _find_transitive_orphans(lockfile, removed_repo_urls)
-
-            if potential_orphans:
-                # Check which orphans are still needed by remaining packages
-                # Re-read updated apm.yml to get remaining deps
-                remaining_deps = builtins.set()
-                try:
-                    with open(apm_yml_path, "r") as f:
-                        updated_data = yaml.safe_load(f) or {}
-                    for dep_str in updated_data.get("dependencies", {}).get("apm", []) or []:
-                        try:
-                            ref = _parse_dependency_entry(dep_str)
-                            remaining_deps.add(ref.get_unique_key())
-                        except (ValueError, TypeError, AttributeError, KeyError):
-                            remaining_deps.add(dep_str)
-                except Exception:
-                    pass
-
-                # Also check remaining lockfile deps that are NOT orphaned
-                for dep in lockfile.get_all_dependencies():
-                    key = dep.get_unique_key()
-                    if key not in potential_orphans and dep.repo_url not in removed_repo_urls:
-                        remaining_deps.add(key)
-
-                # Remove only true orphans (not needed by remaining deps)
-                actual_orphans = potential_orphans - remaining_deps
-                deleted_orphan_paths: list = []
-                for orphan_key in actual_orphans:
-                    orphan_dep = lockfile.get_dependency(orphan_key)
-                    if not orphan_dep:
-                        continue
-                    try:
-                        orphan_ref = DependencyReference.parse(orphan_key)
-                        orphan_path = orphan_ref.get_install_path(apm_modules_dir)
-                    except ValueError:
-                        parts = orphan_key.split("/")
-                        orphan_path = apm_modules_dir.joinpath(*parts) if len(parts) >= 2 else apm_modules_dir / orphan_key
-
-                    if orphan_path.exists():
-                        try:
-                            safe_rmtree(orphan_path, apm_modules_dir)
-                            _rich_info(f"+ Removed transitive dependency {orphan_key} from apm_modules/")
-                            removed_from_modules += 1
-                            deleted_orphan_paths.append(orphan_path)
-                        except Exception as e:
-                            _rich_error(f"x Failed to remove transitive dep {orphan_key}: {e}")
-
-                # Batch parent cleanup  -- single bottom-up pass
-                from ..integration.base_integrator import BaseIntegrator as _BI
-                _BI.cleanup_empty_parents(deleted_orphan_paths, stop_at=apm_modules_dir)
-
-        # Collect deployed_files only for REMOVED packages (direct + transitive)
-        # so sync_integration doesn't iterate paths from packages still installed.
-        from ..integration.base_integrator import BaseIntegrator
-        removed_keys = builtins.set()
-        for pkg in packages_to_remove:
-            try:
-                ref = _parse_dependency_entry(pkg)
-                removed_keys.add(ref.get_unique_key())
-            except (ValueError, TypeError, AttributeError, KeyError):
-                removed_keys.add(pkg)
-        if 'actual_orphans' in locals():
-            removed_keys.update(actual_orphans)
-        all_deployed_files = builtins.set()
-        if lockfile:
-            for dep_key, dep in lockfile.dependencies.items():
-                if dep_key in removed_keys:
-                    all_deployed_files.update(dep.deployed_files)
-        # Normalize path separators once
-        all_deployed_files = BaseIntegrator.normalize_managed_files(all_deployed_files) or builtins.set()
-
-        # Update lockfile: remove entries for all removed packages (direct + transitive)
-        removed_orphan_keys = builtins.set()
-        if lockfile and apm_modules_dir.exists() and 'actual_orphans' in locals():
-            removed_orphan_keys = actual_orphans
-        if lockfile:
-            lockfile_updated = False
-            for pkg in packages_to_remove:
-                try:
-                    ref = _parse_dependency_entry(pkg)
-                    key = ref.get_unique_key()
-                except (ValueError, TypeError, AttributeError, KeyError):
-                    key = pkg
-                if key in lockfile.dependencies:
-                    del lockfile.dependencies[key]
-                    lockfile_updated = True
-            # Also remove orphaned transitive deps from lockfile
-            for orphan_key in removed_orphan_keys:
-                if orphan_key in lockfile.dependencies:
-                    del lockfile.dependencies[orphan_key]
-                    lockfile_updated = True
-            if lockfile_updated:
-                try:
-                    if lockfile.dependencies:
-                        lockfile.write(lockfile_path)
-                    else:
-                        # No deps left  -- remove lockfile
-                        lockfile_path.unlink(missing_ok=True)
-                except Exception:
-                    pass
-
-        # Sync integrations: remove all deployed files and re-integrate from remaining packages
-        prompts_cleaned = 0
-        agents_cleaned = 0
-        commands_cleaned = 0
-        skills_cleaned = 0
-        hooks_cleaned = 0
-        instructions_cleaned = 0
-
-        try:
-            from ..models.apm_package import APMPackage, PackageInfo, PackageType, validate_apm_package
-            from ..integration.prompt_integrator import PromptIntegrator
-            from ..integration.agent_integrator import AgentIntegrator
-            from ..integration.skill_integrator import SkillIntegrator
-            from ..integration.command_integrator import CommandIntegrator
-            from ..integration.hook_integrator import HookIntegrator
-            from ..integration.instruction_integrator import InstructionIntegrator
-
-            apm_package = APMPackage.from_apm_yml(Path("apm.yml"))
-            project_root = Path(".")
-
-            # Use pre-collected deployed_files (captured before lockfile entries were deleted)
-            sync_managed = all_deployed_files if all_deployed_files else None
-
-            # Pre-partition managed files by integration type  -- single O(M)
-            # pass instead of 6x O(M) prefix scans inside each integrator.
-            if sync_managed is not None:
-                _buckets = BaseIntegrator.partition_managed_files(sync_managed)
-            else:
-                _buckets = None
-
-            # Phase 1: Remove all APM-deployed files
-            if Path(".github/prompts").exists():
-                integrator = PromptIntegrator()
-                result = integrator.sync_integration(apm_package, project_root,
-                                                     managed_files=_buckets["prompts"] if _buckets else None)
-                prompts_cleaned = result.get("files_removed", 0)
-
-            if Path(".github/agents").exists():
-                integrator = AgentIntegrator()
-                result = integrator.sync_integration(apm_package, project_root,
-                                                     managed_files=_buckets["agents_github"] if _buckets else None)
-                agents_cleaned = result.get("files_removed", 0)
-
-            if Path(".claude/agents").exists():
-                integrator = AgentIntegrator()
-                result = integrator.sync_integration_claude(apm_package, project_root,
-                                                            managed_files=_buckets["agents_claude"] if _buckets else None)
-                agents_cleaned += result.get("files_removed", 0)
-
-            if Path(".cursor/agents").exists():
-                integrator = AgentIntegrator()
-                result = integrator.sync_integration_cursor(apm_package, project_root,
-                                                            managed_files=_buckets["agents_cursor"] if _buckets else None)
-                agents_cleaned += result.get("files_removed", 0)
-
-            if Path(".opencode/agents").exists():
-                integrator = AgentIntegrator()
-                result = integrator.sync_integration_opencode(apm_package, project_root,
-                                                              managed_files=_buckets["agents_opencode"] if _buckets else None)
-                agents_cleaned += result.get("files_removed", 0)
-
-            if Path(".github/skills").exists() or Path(".claude/skills").exists() or Path(".cursor/skills").exists() or Path(".opencode/skills").exists():
-                integrator = SkillIntegrator()
-                result = integrator.sync_integration(apm_package, project_root,
-                                                     managed_files=_buckets["skills"] if _buckets else None)
-                skills_cleaned = result.get("files_removed", 0)
-
-            if Path(".claude/commands").exists():
-                integrator = CommandIntegrator()
-                result = integrator.sync_integration(apm_package, project_root,
-                                                     managed_files=_buckets["commands"] if _buckets else None)
-                commands_cleaned = result.get("files_removed", 0)
-
-            if Path(".opencode/commands").exists():
-                integrator = CommandIntegrator()
-                result = integrator.sync_integration_opencode(apm_package, project_root,
-                                                              managed_files=_buckets["commands_opencode"] if _buckets else None)
-                commands_cleaned += result.get("files_removed", 0)
-
-            # Clean hooks (.github/hooks/ and .claude/settings.json)
-            hook_integrator_cleanup = HookIntegrator()
-            result = hook_integrator_cleanup.sync_integration(apm_package, project_root,
-                                                              managed_files=_buckets["hooks"] if _buckets else None)
-            hooks_cleaned = result.get("files_removed", 0)
-
-            # Clean instructions (.github/instructions/)
-            if Path(".github/instructions").exists():
-                integrator = InstructionIntegrator()
-                result = integrator.sync_integration(apm_package, project_root,
-                                                     managed_files=_buckets["instructions"] if _buckets else None)
-                instructions_cleaned = result.get("files_removed", 0)
-
-            # Clean Cursor rules (.cursor/rules/)
-            if Path(".cursor/rules").exists():
-                integrator = InstructionIntegrator()
-                result = integrator.sync_integration_cursor(apm_package, project_root,
-                                                            managed_files=_buckets["rules_cursor"] if _buckets else None)
-                instructions_cleaned += result.get("files_removed", 0)
-
-            # Phase 2: Re-integrate from remaining installed packages in apm_modules/
-            # Detect target so we only re-create Claude artefacts when appropriate
-            from ..core.target_detection import (
-                detect_target,
-                should_integrate_claude,
-            )
-            config_target = apm_package.target
-            detected_target, _ = detect_target(
-                project_root=project_root,
-                explicit_target=None,
-                config_target=config_target,
-            )
-            integrate_claude = should_integrate_claude(detected_target)
-
-            prompt_integrator = PromptIntegrator()
-            agent_integrator = AgentIntegrator()
-            skill_integrator = SkillIntegrator()
-            command_integrator = CommandIntegrator()
-            hook_integrator_reint = HookIntegrator()
-            instruction_integrator = InstructionIntegrator()
-
-            for dep in apm_package.get_apm_dependencies():
-                dep_ref = dep if hasattr(dep, 'repo_url') else None
-                if not dep_ref:
-                    continue
-                install_path = dep_ref.get_install_path(Path("apm_modules"))
-                if not install_path.exists():
-                    continue
-
-                # Build minimal PackageInfo for re-integration
-                result = validate_apm_package(install_path)
-                pkg = result.package if result and result.package else None
-                if not pkg:
-                    continue
-                pkg_info = PackageInfo(
-                    package=pkg,
-                    install_path=install_path,
-                    dependency_ref=dep_ref,
-                    package_type=result.package_type if result else None,
-                )
-
-                try:
-                    if prompt_integrator.should_integrate(project_root):
-                        prompt_integrator.integrate_package_prompts(pkg_info, project_root)
-                    if agent_integrator.should_integrate(project_root):
-                        agent_integrator.integrate_package_agents(pkg_info, project_root)
-                        if integrate_claude:
-                            agent_integrator.integrate_package_agents_claude(pkg_info, project_root)
-                        agent_integrator.integrate_package_agents_cursor(pkg_info, project_root)
-                    skill_integrator.integrate_package_skill(pkg_info, project_root)
-                    if integrate_claude:
-                        command_integrator.integrate_package_commands(pkg_info, project_root)
-                    hook_integrator_reint.integrate_package_hooks(pkg_info, project_root)
-                    if integrate_claude:
-                        hook_integrator_reint.integrate_package_hooks_claude(pkg_info, project_root)
-                    hook_integrator_reint.integrate_package_hooks_cursor(pkg_info, project_root)
-                    instruction_integrator.integrate_package_instructions(pkg_info, project_root)
-                    instruction_integrator.integrate_package_instructions_cursor(pkg_info, project_root)
-                except Exception:
-                    pass  # Best effort re-integration
-
-        except Exception:
-            pass  # Best effort cleanup  -- don't report false failures
-
-        # Show cleanup feedback
-        if prompts_cleaned > 0:
-            _rich_info(f"+ Cleaned up {prompts_cleaned} integrated prompt(s)")
-        if agents_cleaned > 0:
-            _rich_info(f"+ Cleaned up {agents_cleaned} integrated agent(s)")
-        if skills_cleaned > 0:
-            _rich_info(f"+ Cleaned up {skills_cleaned} skill(s)")
-        if commands_cleaned > 0:
-            _rich_info(f"+ Cleaned up {commands_cleaned} command(s)")
-        if hooks_cleaned > 0:
-            _rich_info(f"+ Cleaned up {hooks_cleaned} hook(s)")
-        if instructions_cleaned > 0:
-            _rich_info(f"+ Cleaned up {instructions_cleaned} instruction(s)")
-
-        # Clean up stale MCP servers after uninstall
-        try:
-            old_mcp_servers = _pre_uninstall_mcp_servers
-            if old_mcp_servers:
-                # Recompute MCP deps from remaining packages
-                apm_modules_path = Path.cwd() / "apm_modules"
-                remaining_mcp = MCPIntegrator.collect_transitive(apm_modules_path, lockfile_path, trust_private=True)
-                # Also include root-level MCP deps from apm.yml
-                try:
-                    remaining_root_mcp = apm_package.get_mcp_dependencies()
-                except Exception:
-                    remaining_root_mcp = []
-                all_remaining_mcp = MCPIntegrator.deduplicate(remaining_root_mcp + remaining_mcp)
-                new_mcp_servers = MCPIntegrator.get_server_names(all_remaining_mcp)
-                stale_servers = old_mcp_servers - new_mcp_servers
-                if stale_servers:
-                    MCPIntegrator.remove_stale(stale_servers)
-                MCPIntegrator.update_lockfile(new_mcp_servers, lockfile_path)
-        except Exception:
-            _rich_warning("MCP cleanup during uninstall failed")
-
-        # Final summary
-        summary_lines = []
-        summary_lines.append(
-            f"Removed {len(packages_to_remove)} package(s) from apm.yml"
-        )
-        if removed_from_modules > 0:
-            summary_lines.append(
-                f"Removed {removed_from_modules} package(s) from apm_modules/"
-            )
-
-        _rich_success("Uninstall complete: " + ", ".join(summary_lines))
-
-        if packages_not_found:
-            _rich_warning(
-                f"Note: {len(packages_not_found)} package(s) were not found in apm.yml"
-            )
-
-    except Exception as e:
-        _rich_error(f"Error uninstalling packages: {e}")
-        sys.exit(1)
diff --git a/src/apm_cli/commands/uninstall/__init__.py b/src/apm_cli/commands/uninstall/__init__.py
new file mode 100644
index 000000000..a4a8b3706
--- /dev/null
+++ b/src/apm_cli/commands/uninstall/__init__.py
@@ -0,0 +1,23 @@
+"""APM uninstall command."""
+
+from .cli import uninstall
+from .engine import (
+    _parse_dependency_entry,
+    _validate_uninstall_packages,
+    _dry_run_uninstall,
+    _remove_packages_from_disk,
+    _cleanup_transitive_orphans,
+    _sync_integrations_after_uninstall,
+    _cleanup_stale_mcp,
+)
+
+__all__ = [
+    "uninstall",
+    "_parse_dependency_entry",
+    "_validate_uninstall_packages",
+    "_dry_run_uninstall",
+    "_remove_packages_from_disk",
+    "_cleanup_transitive_orphans",
+    "_sync_integrations_after_uninstall",
+    "_cleanup_stale_mcp",
+]
diff --git a/src/apm_cli/commands/uninstall/cli.py b/src/apm_cli/commands/uninstall/cli.py
new file mode 100644
index 000000000..15ab44820
--- /dev/null
+++ b/src/apm_cli/commands/uninstall/cli.py
@@ -0,0 +1,187 @@
+"""APM uninstall command CLI."""
+
+import builtins
+import sys
+from pathlib import Path
+
+import click
+
+from ...constants import APM_MODULES_DIR, APM_YML_FILENAME
+from ...utils.console import _rich_error, _rich_info, _rich_success, _rich_warning
+
+from ...deps.lockfile import LockFile
+from ...models.apm_package import APMPackage, DependencyReference
+from ...integration.mcp_integrator import MCPIntegrator
+
+from .engine import (
+    _parse_dependency_entry,
+    _validate_uninstall_packages,
+    _dry_run_uninstall,
+    _remove_packages_from_disk,
+    _cleanup_transitive_orphans,
+    _sync_integrations_after_uninstall,
+    _cleanup_stale_mcp,
+)
+
+
+@click.command(help="Remove APM packages, their integrated files, and apm.yml entries")
+@click.argument("packages", nargs=-1, required=True)
+@click.option(
+    "--dry-run", is_flag=True, help="Show what would be removed without removing"
+)
+@click.pass_context
+def uninstall(ctx, packages, dry_run):
+    """Remove APM packages from apm.yml and apm_modules (like npm uninstall).
+
+    This command removes packages from both the apm.yml dependencies list
+    and the apm_modules/ directory. It's the opposite of 'apm install <package>'.
+
+    Examples:
+        apm uninstall acme/my-package                # Remove one package
+        apm uninstall org/pkg1 org/pkg2              # Remove multiple packages
+        apm uninstall acme/my-package --dry-run      # Show what would be removed
+    """
+    try:
+        # Check if apm.yml exists
+        if not Path(APM_YML_FILENAME).exists():
+            _rich_error(f"No {APM_YML_FILENAME} found. Run 'apm init' first.")
+            sys.exit(1)
+
+        if not packages:
+            _rich_error("No packages specified. Specify packages to uninstall.")
+            sys.exit(1)
+
+        _rich_info(f"Uninstalling {len(packages)} package(s)...")
+
+        # Read current apm.yml
+        import yaml
+
+        apm_yml_path = Path(APM_YML_FILENAME)
+        try:
+            with open(apm_yml_path, "r") as f:
+                data = yaml.safe_load(f) or {}
+        except Exception as e:
+            _rich_error(f"Failed to read {APM_YML_FILENAME}: {e}")
+            sys.exit(1)
+
+        if "dependencies" not in data:
+            data["dependencies"] = {}
+        if "apm" not in data["dependencies"]:
+            data["dependencies"]["apm"] = []
+
+        current_deps = data["dependencies"]["apm"] or []
+
+        # Step 1: Validate packages
+        packages_to_remove, packages_not_found = _validate_uninstall_packages(packages, current_deps)
+        if not packages_to_remove:
+            _rich_warning("No packages found in apm.yml to remove")
+            return
+
+        # Step 2: Dry run
+        if dry_run:
+            _dry_run_uninstall(packages_to_remove, Path(APM_MODULES_DIR))
+            return
+
+        # Step 3: Remove from apm.yml
+        for package in packages_to_remove:
+            current_deps.remove(package)
+            _rich_info(f"Removed {package} from apm.yml")
+        data["dependencies"]["apm"] = current_deps
+        try:
+            with open(apm_yml_path, "w") as f:
+                yaml.safe_dump(data, f, default_flow_style=False, sort_keys=False)
+            _rich_success(f"Updated {APM_YML_FILENAME} (removed {len(packages_to_remove)} package(s))")
+        except Exception as e:
+            _rich_error(f"Failed to write {APM_YML_FILENAME}: {e}")
+            sys.exit(1)
+
+        # Step 4: Load lockfile and capture pre-uninstall MCP state
+        apm_modules_dir = Path(APM_MODULES_DIR)
+        from ...deps.lockfile import LockFile, get_lockfile_path
+        lockfile_path = get_lockfile_path(Path("."))
+        lockfile = LockFile.read(lockfile_path)
+        _pre_uninstall_mcp_servers = builtins.set(lockfile.mcp_servers) if lockfile else builtins.set()
+
+        # Step 5: Remove packages from disk
+        removed_from_modules = _remove_packages_from_disk(packages_to_remove, apm_modules_dir)
+
+        # Step 6: Cleanup transitive orphans
+        orphan_removed, actual_orphans = _cleanup_transitive_orphans(
+            lockfile, packages_to_remove, apm_modules_dir, apm_yml_path
+        )
+        removed_from_modules += orphan_removed
+
+        # Step 7: Collect deployed files for removed packages (before lockfile mutation)
+        from ...integration.base_integrator import BaseIntegrator
+        removed_keys = builtins.set()
+        for pkg in packages_to_remove:
+            try:
+                ref = _parse_dependency_entry(pkg)
+                removed_keys.add(ref.get_unique_key())
+            except (ValueError, TypeError, AttributeError, KeyError):
+                removed_keys.add(pkg)
+        removed_keys.update(actual_orphans)
+        all_deployed_files = builtins.set()
+        if lockfile:
+            for dep_key, dep in lockfile.dependencies.items():
+                if dep_key in removed_keys:
+                    all_deployed_files.update(dep.deployed_files)
+        all_deployed_files = BaseIntegrator.normalize_managed_files(all_deployed_files) or builtins.set()
+
+        # Step 8: Update lockfile
+        if lockfile:
+            lockfile_updated = False
+            for pkg in packages_to_remove:
+                try:
+                    ref = _parse_dependency_entry(pkg)
+                    key = ref.get_unique_key()
+                except (ValueError, TypeError, AttributeError, KeyError):
+                    key = pkg
+                if key in lockfile.dependencies:
+                    del lockfile.dependencies[key]
+                    lockfile_updated = True
+            for orphan_key in actual_orphans:
+                if orphan_key in lockfile.dependencies:
+                    del lockfile.dependencies[orphan_key]
+                    lockfile_updated = True
+            if lockfile_updated:
+                try:
+                    if lockfile.dependencies:
+                        lockfile.write(lockfile_path)
+                    else:
+                        lockfile_path.unlink(missing_ok=True)
+                except Exception:
+                    _rich_warning("Failed to update lockfile — it may be out of sync with uninstalled packages.")
+
+        # Step 9: Sync integrations
+        cleaned = {"prompts": 0, "agents": 0, "skills": 0, "commands": 0, "hooks": 0, "instructions": 0}
+        try:
+            apm_package = APMPackage.from_apm_yml(Path(APM_YML_FILENAME))
+            project_root = Path(".")
+            cleaned = _sync_integrations_after_uninstall(apm_package, project_root, all_deployed_files)
+        except Exception:
+            pass  # Best effort cleanup
+
+        for label, count in cleaned.items():
+            if count > 0:
+                _rich_info(f"\u2713 Cleaned up {count} integrated {label}")
+
+        # Step 10: MCP cleanup
+        try:
+            apm_package = APMPackage.from_apm_yml(Path(APM_YML_FILENAME))
+            _cleanup_stale_mcp(apm_package, lockfile, lockfile_path, _pre_uninstall_mcp_servers)
+        except Exception:
+            _rich_warning("MCP cleanup during uninstall failed")
+
+        # Final summary
+        summary_lines = [f"Removed {len(packages_to_remove)} package(s) from apm.yml"]
+        if removed_from_modules > 0:
+            summary_lines.append(f"Removed {removed_from_modules} package(s) from apm_modules/")
+        _rich_success("Uninstall complete: " + ", ".join(summary_lines))
+
+        if packages_not_found:
+            _rich_warning(f"Note: {len(packages_not_found)} package(s) were not found in apm.yml")
+
+    except Exception as e:
+        _rich_error(f"Error uninstalling packages: {e}")
+        sys.exit(1)
diff --git a/src/apm_cli/commands/uninstall/engine.py b/src/apm_cli/commands/uninstall/engine.py
new file mode 100644
index 000000000..41d3db4c8
--- /dev/null
+++ b/src/apm_cli/commands/uninstall/engine.py
@@ -0,0 +1,383 @@
+"""APM uninstall engine  -- validation, removal, and cleanup helpers."""
+
+import builtins
+from pathlib import Path
+
+from ...constants import APM_MODULES_DIR, APM_YML_FILENAME
+from ...utils.console import _rich_error, _rich_info, _rich_success, _rich_warning
+from ...utils.path_security import PathTraversalError, safe_rmtree
+
+from ...deps.lockfile import LockFile
+from ...models.apm_package import APMPackage, DependencyReference
+from ...integration.mcp_integrator import MCPIntegrator
+
+
+def _parse_dependency_entry(dep_entry):
+    """Parse a dependency entry from apm.yml into a DependencyReference."""
+    if isinstance(dep_entry, DependencyReference):
+        return dep_entry
+    if isinstance(dep_entry, str):
+        return DependencyReference.parse(dep_entry)
+    if isinstance(dep_entry, builtins.dict):
+        return DependencyReference.parse_from_dict(dep_entry)
+    raise ValueError(f"Unsupported dependency entry type: {type(dep_entry).__name__}")
+
+
+def _validate_uninstall_packages(packages, current_deps):
+    """Validate which packages can be removed and return matched/unmatched lists."""
+    packages_to_remove = []
+    packages_not_found = []
+
+    for package in packages:
+        if "/" not in package:
+            _rich_error(f"Invalid package format: {package}. Use 'owner/repo' format.")
+            continue
+
+        matched_dep = None
+        try:
+            pkg_ref = DependencyReference.parse(package)
+            pkg_identity = pkg_ref.get_identity()
+        except Exception:
+            pkg_identity = package
+
+        for dep_entry in current_deps:
+            try:
+                dep_ref = _parse_dependency_entry(dep_entry)
+                if dep_ref.get_identity() == pkg_identity:
+                    matched_dep = dep_entry
+                    break
+            except (ValueError, TypeError, AttributeError, KeyError):
+                dep_str = dep_entry if isinstance(dep_entry, str) else str(dep_entry)
+                if dep_str == package:
+                    matched_dep = dep_entry
+                    break
+
+        if matched_dep is not None:
+            packages_to_remove.append(matched_dep)
+            _rich_info(f"\u2713 {package} - found in apm.yml")
+        else:
+            packages_not_found.append(package)
+            _rich_warning(f"\u2717 {package} - not found in apm.yml")
+
+    return packages_to_remove, packages_not_found
+
+
+def _dry_run_uninstall(packages_to_remove, apm_modules_dir):
+    """Show what would be removed without making changes."""
+    _rich_info(f"Dry run: Would remove {len(packages_to_remove)} package(s):")
+    for pkg in packages_to_remove:
+        _rich_info(f"  - {pkg} from apm.yml")
+        try:
+            dep_ref = _parse_dependency_entry(pkg)
+            package_path = dep_ref.get_install_path(apm_modules_dir)
+        except (ValueError, TypeError, AttributeError, KeyError):
+            pkg_str = pkg if isinstance(pkg, str) else str(pkg)
+            package_path = apm_modules_dir / pkg_str.split("/")[-1]
+        if apm_modules_dir.exists() and package_path.exists():
+            _rich_info(f"  - {pkg} from apm_modules/")
+
+    from ...deps.lockfile import LockFile, get_lockfile_path
+    lockfile_path = get_lockfile_path(Path("."))
+    lockfile = LockFile.read(lockfile_path)
+    if lockfile:
+        removed_repo_urls = builtins.set()
+        for pkg in packages_to_remove:
+            try:
+                ref = _parse_dependency_entry(pkg)
+                removed_repo_urls.add(ref.repo_url)
+            except (ValueError, TypeError, AttributeError, KeyError):
+                removed_repo_urls.add(pkg)
+        queue = builtins.list(removed_repo_urls)
+        potential_orphans = builtins.set()
+        while queue:
+            parent_url = queue.pop()
+            for dep in lockfile.get_all_dependencies():
+                key = dep.get_unique_key()
+                if key in potential_orphans:
+                    continue
+                if dep.resolved_by and dep.resolved_by == parent_url:
+                    potential_orphans.add(key)
+                    queue.append(dep.repo_url)
+        if potential_orphans:
+            _rich_info(f"  Transitive dependencies that would be removed:")
+            for orphan_key in sorted(potential_orphans):
+                _rich_info(f"    - {orphan_key}")
+
+    _rich_success("Dry run complete - no changes made")
+
+
+def _remove_packages_from_disk(packages_to_remove, apm_modules_dir):
+    """Remove direct packages from apm_modules/ and return removal count."""
+    removed = 0
+    if not apm_modules_dir.exists():
+        return removed
+
+    deleted_pkg_paths = []
+    for package in packages_to_remove:
+        try:
+            dep_ref = _parse_dependency_entry(package)
+            package_path = dep_ref.get_install_path(apm_modules_dir)
+        except (PathTraversalError,) as e:
+            _rich_error(f"x Refusing to remove {package}: {e}")
+            continue
+        except (ValueError, TypeError, AttributeError, KeyError):
+            package_str = package if isinstance(package, str) else str(package)
+            repo_parts = package_str.split("/")
+            if len(repo_parts) >= 2:
+                package_path = apm_modules_dir.joinpath(*repo_parts)
+            else:
+                package_path = apm_modules_dir / package_str
+
+        if package_path.exists():
+            try:
+                safe_rmtree(package_path, apm_modules_dir)
+                _rich_info(f"+ Removed {package} from apm_modules/")
+                removed += 1
+                deleted_pkg_paths.append(package_path)
+            except Exception as e:
+                _rich_error(f"x Failed to remove {package} from apm_modules/: {e}")
+        else:
+            _rich_warning(f"Package {package} not found in apm_modules/")
+
+    from ...integration.base_integrator import BaseIntegrator as _BI2
+    _BI2.cleanup_empty_parents(deleted_pkg_paths, stop_at=apm_modules_dir)
+    return removed
+
+
+def _cleanup_transitive_orphans(lockfile, packages_to_remove, apm_modules_dir, apm_yml_path):
+    """Remove orphaned transitive deps and return (removed_count, actual_orphan_keys)."""
+    import yaml
+
+    if not lockfile or not apm_modules_dir.exists():
+        return 0, builtins.set()
+
+    removed_repo_urls = builtins.set()
+    for pkg in packages_to_remove:
+        try:
+            ref = _parse_dependency_entry(pkg)
+            removed_repo_urls.add(ref.repo_url)
+        except (ValueError, TypeError, AttributeError, KeyError):
+            removed_repo_urls.add(pkg)
+
+    # Find transitive orphans recursively
+    orphans = builtins.set()
+    queue = builtins.list(removed_repo_urls)
+    while queue:
+        parent_url = queue.pop()
+        for dep in lockfile.get_all_dependencies():
+            key = dep.get_unique_key()
+            if key in orphans:
+                continue
+            if dep.resolved_by and dep.resolved_by == parent_url:
+                orphans.add(key)
+                queue.append(dep.repo_url)
+
+    if not orphans:
+        return 0, builtins.set()
+
+    # Determine remaining deps to avoid removing still-needed packages
+    remaining_deps = builtins.set()
+    try:
+        with open(apm_yml_path, "r") as f:
+            updated_data = yaml.safe_load(f) or {}
+        for dep_str in updated_data.get("dependencies", {}).get("apm", []) or []:
+            try:
+                ref = _parse_dependency_entry(dep_str)
+                remaining_deps.add(ref.get_unique_key())
+            except (ValueError, TypeError, AttributeError, KeyError):
+                remaining_deps.add(dep_str)
+    except Exception:
+        pass
+
+    for dep in lockfile.get_all_dependencies():
+        key = dep.get_unique_key()
+        if key not in orphans and dep.repo_url not in removed_repo_urls:
+            remaining_deps.add(key)
+
+    actual_orphans = orphans - remaining_deps
+    removed = 0
+    deleted_orphan_paths = []
+    for orphan_key in actual_orphans:
+        orphan_dep = lockfile.get_dependency(orphan_key)
+        if not orphan_dep:
+            continue
+        try:
+            orphan_ref = DependencyReference.parse(orphan_key)
+            orphan_path = orphan_ref.get_install_path(apm_modules_dir)
+        except ValueError:
+            parts = orphan_key.split("/")
+            orphan_path = apm_modules_dir.joinpath(*parts) if len(parts) >= 2 else apm_modules_dir / orphan_key
+
+        if orphan_path.exists():
+            try:
+                safe_rmtree(orphan_path, apm_modules_dir)
+                _rich_info(f"+ Removed transitive dependency {orphan_key} from apm_modules/")
+                removed += 1
+                deleted_orphan_paths.append(orphan_path)
+            except Exception as e:
+                _rich_error(f"x Failed to remove transitive dep {orphan_key}: {e}")
+
+    from ...integration.base_integrator import BaseIntegrator as _BI
+    _BI.cleanup_empty_parents(deleted_orphan_paths, stop_at=apm_modules_dir)
+    return removed, actual_orphans
+
+
+def _sync_integrations_after_uninstall(apm_package, project_root, all_deployed_files):
+    """Remove deployed files and re-integrate from remaining packages."""
+    from ...integration.base_integrator import BaseIntegrator
+    from ...models.apm_package import PackageInfo, validate_apm_package
+    from ...integration.prompt_integrator import PromptIntegrator
+    from ...integration.agent_integrator import AgentIntegrator
+    from ...integration.skill_integrator import SkillIntegrator
+    from ...integration.command_integrator import CommandIntegrator
+    from ...integration.hook_integrator import HookIntegrator
+    from ...integration.instruction_integrator import InstructionIntegrator
+
+    sync_managed = all_deployed_files if all_deployed_files else None
+    if sync_managed is not None:
+        _buckets = BaseIntegrator.partition_managed_files(sync_managed)
+    else:
+        _buckets = None
+
+    counts = {"prompts": 0, "agents": 0, "skills": 0, "commands": 0, "hooks": 0, "instructions": 0}
+
+    # Phase 1: Remove all APM-deployed files
+    if Path(".github/prompts").exists():
+        integrator = PromptIntegrator()
+        result = integrator.sync_integration(apm_package, project_root,
+                                             managed_files=_buckets["prompts"] if _buckets else None)
+        counts["prompts"] = result.get("files_removed", 0)
+
+    if Path(".github/agents").exists():
+        integrator = AgentIntegrator()
+        result = integrator.sync_integration(apm_package, project_root,
+                                             managed_files=_buckets["agents_github"] if _buckets else None)
+        counts["agents"] = result.get("files_removed", 0)
+
+    if Path(".claude/agents").exists():
+        integrator = AgentIntegrator()
+        result = integrator.sync_integration_claude(apm_package, project_root,
+                                                    managed_files=_buckets["agents_claude"] if _buckets else None)
+        counts["agents"] += result.get("files_removed", 0)
+
+    if Path(".cursor/agents").exists():
+        integrator = AgentIntegrator()
+        result = integrator.sync_integration_cursor(apm_package, project_root,
+                                                    managed_files=_buckets["agents_cursor"] if _buckets else None)
+        counts["agents"] += result.get("files_removed", 0)
+
+    if Path(".opencode/agents").exists():
+        integrator = AgentIntegrator()
+        result = integrator.sync_integration_opencode(apm_package, project_root,
+                                                      managed_files=_buckets["agents_opencode"] if _buckets else None)
+        counts["agents"] += result.get("files_removed", 0)
+
+    if Path(".github/skills").exists() or Path(".claude/skills").exists() or Path(".cursor/skills").exists() or Path(".opencode/skills").exists():
+        integrator = SkillIntegrator()
+        result = integrator.sync_integration(apm_package, project_root,
+                                             managed_files=_buckets["skills"] if _buckets else None)
+        counts["skills"] = result.get("files_removed", 0)
+
+    if Path(".claude/commands").exists():
+        integrator = CommandIntegrator()
+        result = integrator.sync_integration(apm_package, project_root,
+                                             managed_files=_buckets["commands"] if _buckets else None)
+        counts["commands"] = result.get("files_removed", 0)
+
+    if Path(".opencode/commands").exists():
+        integrator = CommandIntegrator()
+        result = integrator.sync_integration_opencode(apm_package, project_root,
+                                                      managed_files=_buckets["commands_opencode"] if _buckets else None)
+        counts["commands"] += result.get("files_removed", 0)
+
+    hook_integrator_cleanup = HookIntegrator()
+    result = hook_integrator_cleanup.sync_integration(apm_package, project_root,
+                                                      managed_files=_buckets["hooks"] if _buckets else None)
+    counts["hooks"] = result.get("files_removed", 0)
+
+    if Path(".github/instructions").exists():
+        integrator = InstructionIntegrator()
+        result = integrator.sync_integration(apm_package, project_root,
+                                             managed_files=_buckets["instructions"] if _buckets else None)
+        counts["instructions"] = result.get("files_removed", 0)
+
+    # Clean Cursor rules (.cursor/rules/)
+    if Path(".cursor/rules").exists():
+        integrator = InstructionIntegrator()
+        result = integrator.sync_integration_cursor(apm_package, project_root,
+                                                    managed_files=_buckets["rules_cursor"] if _buckets else None)
+        counts["instructions"] += result.get("files_removed", 0)
+
+    # Phase 2: Re-integrate from remaining installed packages
+    from ...core.target_detection import detect_target, should_integrate_claude
+    config_target = apm_package.target
+    detected_target, _ = detect_target(
+        project_root=project_root, explicit_target=None, config_target=config_target,
+    )
+    integrate_claude = should_integrate_claude(detected_target)
+
+    prompt_integrator = PromptIntegrator()
+    agent_integrator = AgentIntegrator()
+    skill_integrator = SkillIntegrator()
+    command_integrator = CommandIntegrator()
+    hook_integrator_reint = HookIntegrator()
+    instruction_integrator_reint = InstructionIntegrator()
+
+    for dep in apm_package.get_apm_dependencies():
+        dep_ref = dep if hasattr(dep, 'repo_url') else None
+        if not dep_ref:
+            continue
+        install_path = dep_ref.get_install_path(Path(APM_MODULES_DIR))
+        if not install_path.exists():
+            continue
+
+        result = validate_apm_package(install_path)
+        pkg = result.package if result and result.package else None
+        if not pkg:
+            continue
+        pkg_info = PackageInfo(
+            package=pkg, install_path=install_path,
+            dependency_ref=dep_ref,
+            package_type=result.package_type if result else None,
+        )
+
+        try:
+            if prompt_integrator.should_integrate(project_root):
+                prompt_integrator.integrate_package_prompts(pkg_info, project_root)
+            if agent_integrator.should_integrate(project_root):
+                agent_integrator.integrate_package_agents(pkg_info, project_root)
+                if integrate_claude:
+                    agent_integrator.integrate_package_agents_claude(pkg_info, project_root)
+                agent_integrator.integrate_package_agents_cursor(pkg_info, project_root)
+            skill_integrator.integrate_package_skill(pkg_info, project_root)
+            if integrate_claude:
+                command_integrator.integrate_package_commands(pkg_info, project_root)
+            hook_integrator_reint.integrate_package_hooks(pkg_info, project_root)
+            if integrate_claude:
+                hook_integrator_reint.integrate_package_hooks_claude(pkg_info, project_root)
+            hook_integrator_reint.integrate_package_hooks_cursor(pkg_info, project_root)
+            instruction_integrator_reint.integrate_package_instructions(pkg_info, project_root)
+            instruction_integrator_reint.integrate_package_instructions_cursor(pkg_info, project_root)
+        except Exception:
+            pkg_id = dep_ref.get_identity() if hasattr(dep_ref, "get_identity") else str(dep_ref)
+            _rich_warning(f"Best-effort re-integration skipped for {pkg_id}")
+
+    return counts
+
+
+def _cleanup_stale_mcp(apm_package, lockfile, lockfile_path, old_mcp_servers):
+    """Remove MCP servers that are no longer needed after uninstall."""
+    if not old_mcp_servers:
+        return
+    apm_modules_path = Path.cwd() / APM_MODULES_DIR
+    remaining_mcp = MCPIntegrator.collect_transitive(apm_modules_path, lockfile_path, trust_private=True)
+    try:
+        remaining_root_mcp = apm_package.get_mcp_dependencies()
+    except Exception:
+        remaining_root_mcp = []
+    all_remaining_mcp = MCPIntegrator.deduplicate(remaining_root_mcp + remaining_mcp)
+    new_mcp_servers = MCPIntegrator.get_server_names(all_remaining_mcp)
+    stale_servers = old_mcp_servers - new_mcp_servers
+    if stale_servers:
+        MCPIntegrator.remove_stale(stale_servers)
+    MCPIntegrator.update_lockfile(new_mcp_servers, lockfile_path)
diff --git a/src/apm_cli/constants.py b/src/apm_cli/constants.py
new file mode 100644
index 000000000..12d3372ac
--- /dev/null
+++ b/src/apm_cli/constants.py
@@ -0,0 +1,31 @@
+"""Shared constants for the APM CLI."""
+
+from enum import Enum
+
+
+# ---------------------------------------------------------------------------
+# Enums
+# ---------------------------------------------------------------------------
+
+
+class InstallMode(Enum):
+    """Controls which dependency types are installed."""
+    ALL = "all"
+    APM = "apm"
+    MCP = "mcp"
+
+
+# ---------------------------------------------------------------------------
+# File and directory names
+# ---------------------------------------------------------------------------
+APM_YML_FILENAME = "apm.yml"
+APM_LOCK_FILENAME = "apm.lock"
+APM_MODULES_DIR = "apm_modules"
+APM_DIR = ".apm"
+SKILL_MD_FILENAME = "SKILL.md"
+AGENTS_MD_FILENAME = "AGENTS.md"
+CLAUDE_MD_FILENAME = "CLAUDE.md"
+GITHUB_DIR = ".github"
+CLAUDE_DIR = ".claude"
+GITIGNORE_FILENAME = ".gitignore"
+APM_MODULES_GITIGNORE_PATTERN = "apm_modules/"
diff --git a/src/apm_cli/models/__init__.py b/src/apm_cli/models/__init__.py
index e0d303a04..31b3cb647 100644
--- a/src/apm_cli/models/__init__.py
+++ b/src/apm_cli/models/__init__.py
@@ -16,6 +16,7 @@
     ValidationResult,
     validate_apm_package,
 )
+from .results import InstallResult, PrimitiveCounts
 
 __all__ = [
     # Core
@@ -35,4 +36,7 @@
     "ValidationError",
     "ValidationResult",
     "validate_apm_package",
+    # Results
+    "InstallResult",
+    "PrimitiveCounts",
 ]
\ No newline at end of file
diff --git a/src/apm_cli/models/dependency.py b/src/apm_cli/models/dependency.py
deleted file mode 100644
index 9567a1eb2..000000000
--- a/src/apm_cli/models/dependency.py
+++ /dev/null
@@ -1,1189 +0,0 @@
-"""Dependency reference models and Git reference utilities."""
-
-import re
-import urllib.parse
-from dataclasses import dataclass
-from enum import Enum
-from pathlib import Path
-from typing import Any, Dict, List, Optional
-
-from ..utils.path_security import PathTraversalError, ensure_path_within
-from ..utils.github_host import (
-    default_host,
-    is_artifactory_path,
-    is_azure_devops_hostname,
-    is_github_hostname,
-    is_supported_git_host,
-    parse_artifactory_path,
-    unsupported_host_error,
-)
-from .validation import InvalidVirtualPackageExtensionError
-
-
-class GitReferenceType(Enum):
-    """Types of Git references supported."""
-    BRANCH = "branch"
-    TAG = "tag" 
-    COMMIT = "commit"
-
-
-@dataclass
-class ResolvedReference:
-    """Represents a resolved Git reference."""
-    original_ref: str
-    ref_type: GitReferenceType
-    resolved_commit: Optional[str] = None
-    ref_name: str = ""  # The actual branch/tag/commit name
-    
-    def __str__(self) -> str:
-        """String representation of resolved reference."""
-        if not self.resolved_commit:
-            return self.ref_name
-        if self.ref_type == GitReferenceType.COMMIT:
-            return f"{self.resolved_commit[:8]}"
-        return f"{self.ref_name} ({self.resolved_commit[:8]})"
-
-
-@dataclass 
-class DependencyReference:
-    """Represents a reference to an APM dependency."""
-    repo_url: str  # e.g., "user/repo" for GitHub or "org/project/repo" for Azure DevOps
-    host: Optional[str] = None  # Optional host (github.com, dev.azure.com, or enterprise host)
-    reference: Optional[str] = None  # e.g., "main", "v1.0.0", "abc123"
-    alias: Optional[str] = None  # Optional alias for the dependency
-    virtual_path: Optional[str] = None  # Path for virtual packages (e.g., "prompts/file.prompt.md")
-    is_virtual: bool = False  # True if this is a virtual package (individual file or collection)
-    
-    # Azure DevOps specific fields (ADO uses org/project/repo structure)
-    ado_organization: Optional[str] = None  # e.g., "dmeppiel-org"
-    ado_project: Optional[str] = None       # e.g., "market-js-app"
-    ado_repo: Optional[str] = None          # e.g., "compliance-rules"
-    
-    # Local path dependency fields
-    is_local: bool = False  # True if this is a local filesystem dependency
-    local_path: Optional[str] = None  # Original local path string (e.g., "./packages/my-pkg")
-
-    artifactory_prefix: Optional[str] = None  # e.g., "artifactory/github" (repo key path)
-
-    # Supported file extensions for virtual packages
-    VIRTUAL_FILE_EXTENSIONS = ('.prompt.md', '.instructions.md', '.chatmode.md', '.agent.md')
-
-    def is_artifactory(self) -> bool:
-        """Check if this reference points to a JFrog Artifactory VCS repository."""
-        return self.artifactory_prefix is not None
-
-    def is_azure_devops(self) -> bool:
-        """Check if this reference points to Azure DevOps."""
-        from ..utils.github_host import is_azure_devops_hostname
-        return self.host is not None and is_azure_devops_hostname(self.host)
-    
-    def is_virtual_file(self) -> bool:
-        """Check if this is a virtual file package (individual file)."""
-        if not self.is_virtual or not self.virtual_path:
-            return False
-        return any(self.virtual_path.endswith(ext) for ext in self.VIRTUAL_FILE_EXTENSIONS)
-    
-    def is_virtual_collection(self) -> bool:
-        """Check if this is a virtual collection package."""
-        if not self.is_virtual or not self.virtual_path:
-            return False
-        # Collections have /collections/ in their path or start with collections/
-        return '/collections/' in self.virtual_path or self.virtual_path.startswith('collections/')
-    
-    def is_virtual_subdirectory(self) -> bool:
-        """Check if this is a virtual subdirectory package (e.g., Claude Skill).
-        
-        A subdirectory package is a virtual package that:
-        - Has a virtual_path that is NOT a file extension we recognize
-        - Is NOT a collection (doesn't have /collections/ in path)
-        - Is a directory path (likely containing SKILL.md or apm.yml)
-        
-        Examples:
-            - ComposioHQ/awesome-claude-skills/brand-guidelines -> True
-            - owner/repo/prompts/file.prompt.md -> False (is_virtual_file)
-            - owner/repo/collections/name -> False (is_virtual_collection)
-        """
-        if not self.is_virtual or not self.virtual_path:
-            return False
-        # Not a file and not a collection = subdirectory
-        return not self.is_virtual_file() and not self.is_virtual_collection()
-    
-    def get_virtual_package_name(self) -> str:
-        """Generate a package name for this virtual package.
-        
-        For virtual packages, we create a sanitized name from the path:
-        - owner/repo/prompts/code-review.prompt.md -> repo-code-review
-        - owner/repo/collections/project-planning -> repo-project-planning
-        - owner/repo/collections/project-planning.collection.yml -> repo-project-planning
-        """
-        if not self.is_virtual or not self.virtual_path:
-            return self.repo_url.split('/')[-1]  # Return repo name as fallback
-        
-        # Extract repo name and file/collection name
-        repo_parts = self.repo_url.split('/')
-        repo_name = repo_parts[-1] if repo_parts else "package"
-        
-        # Get the basename without extension
-        path_parts = self.virtual_path.split('/')
-        if self.is_virtual_collection():
-            # For collections: use the collection name without extension
-            # collections/project-planning -> project-planning
-            # collections/project-planning.collection.yml -> project-planning
-            collection_name = path_parts[-1]
-            # Strip .collection.yml/.collection.yaml extension if present
-            for ext in ('.collection.yml', '.collection.yaml'):
-                if collection_name.endswith(ext):
-                    collection_name = collection_name[:-len(ext)]
-                    break
-            return f"{repo_name}-{collection_name}"
-        else:
-            # For individual files: use the filename without extension
-            # prompts/code-review.prompt.md -> code-review
-            filename = path_parts[-1]
-            for ext in self.VIRTUAL_FILE_EXTENSIONS:
-                if filename.endswith(ext):
-                    filename = filename[:-len(ext)]
-                    break
-            return f"{repo_name}-{filename}"
-    
-    @staticmethod
-    def is_local_path(dep_str: str) -> bool:
-        """Check if a dependency string looks like a local filesystem path.
-        
-        Local paths start with './', '../', '/', or '~'.
-        Protocol-relative URLs ('//...') are explicitly excluded.
-        """
-        s = dep_str.strip()
-        # Reject protocol-relative URLs ('//...')
-        if s.startswith('//'):
-            return False
-        return s.startswith(('./','../', '/', '~/', '~\\', '.\\', '..\\'))
-
-    def get_unique_key(self) -> str:
-        """Get a unique key for this dependency for deduplication.
-        
-        For regular packages: repo_url
-        For virtual packages: repo_url + virtual_path to ensure uniqueness
-        For local packages: the local_path
-        
-        Returns:
-            str: Unique key for this dependency
-        """
-        if self.is_local and self.local_path:
-            return self.local_path
-        if self.is_virtual and self.virtual_path:
-            return f"{self.repo_url}/{self.virtual_path}"
-        return self.repo_url
-    
-    def to_canonical(self) -> str:
-        """Return the canonical form of this dependency for storage in apm.yml.
-        
-        Follows the Docker-style default-registry convention:
-        - Default host (github.com) is stripped  ->  owner/repo
-        - Non-default hosts are preserved         ->  gitlab.com/owner/repo
-        - Virtual paths are appended              ->  owner/repo/path/to/thing
-        - Refs are appended with #                ->  owner/repo#v1.0
-        - Local paths are returned as-is          ->  ./packages/my-pkg
-        
-        No .git suffix, no https://, no git@  -- just the canonical identifier.
-        
-        Returns:
-            str: Canonical dependency string
-        """
-        if self.is_local and self.local_path:
-            return self.local_path
-
-        host = self.host or default_host()
-        is_default = host.lower() == default_host().lower()
-        
-        # Start with optional host prefix
-        if is_default and not self.artifactory_prefix:
-            result = self.repo_url
-        elif self.artifactory_prefix:
-            result = f"{host}/{self.artifactory_prefix}/{self.repo_url}"
-        else:
-            result = f"{host}/{self.repo_url}"
-
-        # Append virtual path for virtual packages
-        if self.is_virtual and self.virtual_path:
-            result = f"{result}/{self.virtual_path}"
-        
-        # Append reference (branch, tag, commit)
-        if self.reference:
-            result = f"{result}#{self.reference}"
-        
-        return result
-    
-    def get_identity(self) -> str:
-        """Return the identity of this dependency (canonical form without ref/alias).
-        
-        Two deps with the same identity are the same package, regardless of
-        which ref or alias they specify. Used for duplicate detection and uninstall matching.
-        
-        Returns:
-            str: Identity string (e.g., "owner/repo" or "gitlab.com/owner/repo/path")
-        """
-        if self.is_local and self.local_path:
-            return self.local_path
-
-        host = self.host or default_host()
-        is_default = host.lower() == default_host().lower()
-        
-        if is_default and not self.artifactory_prefix:
-            result = self.repo_url
-        elif self.artifactory_prefix:
-            result = f"{host}/{self.artifactory_prefix}/{self.repo_url}"
-        else:
-            result = f"{host}/{self.repo_url}"
-
-        if self.is_virtual and self.virtual_path:
-            result = f"{result}/{self.virtual_path}"
-        
-        return result
-    
-    @staticmethod
-    def canonicalize(raw: str) -> str:
-        """Parse any raw input form and return its canonical storage form.
-        
-        Convenience method that combines parse() + to_canonical().
-        
-        Args:
-            raw: Any supported input form (shorthand, FQDN, HTTPS, SSH, etc.)
-            
-        Returns:
-            str: Canonical form for apm.yml storage
-        """
-        return DependencyReference.parse(raw).to_canonical()
-    
-    def get_canonical_dependency_string(self) -> str:
-        """Get the host-blind canonical string for filesystem and orphan-detection matching.
-        
-        This returns repo_url (+ virtual_path) without host prefix  -- it matches
-        the filesystem layout in apm_modules/ which is also host-blind.
-        
-        For identity-based matching that includes non-default hosts, use get_identity().
-        For the full canonical form suitable for apm.yml storage, use to_canonical().
-        
-        Returns:
-            str: Host-blind canonical string (e.g., "owner/repo")
-        """
-        return self.get_unique_key()
-    
-    def get_install_path(self, apm_modules_dir: Path) -> Path:
-        """Get the canonical filesystem path where this package should be installed.
-        
-        This is the single source of truth for where a package lives in apm_modules/.
-        
-        For regular packages:
-            - GitHub: apm_modules/owner/repo/
-            - ADO: apm_modules/org/project/repo/
-        
-        For virtual file/collection packages:
-            - GitHub: apm_modules/owner/<virtual-package-name>/
-            - ADO: apm_modules/org/project/<virtual-package-name>/
-        
-        For subdirectory packages (Claude Skills, nested APM packages):
-            - GitHub: apm_modules/owner/repo/subdir/path/
-            - ADO: apm_modules/org/project/repo/subdir/path/
-        
-        For local packages:
-            - apm_modules/_local/<directory-name>/
-        
-        Args:
-            apm_modules_dir: Path to the apm_modules directory
-            
-        Returns:
-            Path: Absolute path to the package installation directory
-            
-        Raises:
-            PathTraversalError: If the computed path escapes apm_modules_dir
-        """
-        if self.is_local and self.local_path:
-            pkg_dir_name = Path(self.local_path).name
-            if pkg_dir_name in ('', '.', '..'):
-                raise PathTraversalError(
-                    f"Invalid local package path '{self.local_path}': "
-                    f"basename must not be empty, '.', or '..'"
-                )
-            result = apm_modules_dir / "_local" / pkg_dir_name
-            ensure_path_within(result, apm_modules_dir)
-            return result
-
-        repo_parts = self.repo_url.split("/")
-
-        # Security: reject traversal in repo_url segments (catches lockfile injection)
-        if any(seg in ('.', '..') for seg in repo_parts):
-            raise PathTraversalError(
-                f"Invalid repo_url '{self.repo_url}': "
-                f"path segments must not be '.' or '..'"
-            )
-
-        # Security: reject traversal in virtual_path (catches lockfile injection)
-        if self.virtual_path and any(
-            seg in ('.', '..') for seg in self.virtual_path.replace('\\', '/').split('/')
-        ):
-            raise PathTraversalError(
-                f"Invalid virtual_path '{self.virtual_path}': "
-                f"path segments must not be '.' or '..'"
-            )
-        result: Path | None = None
-        
-        if self.is_virtual:
-            # Subdirectory packages (like Claude Skills) should use natural path structure
-            if self.is_virtual_subdirectory():
-                # Use repo path + subdirectory path
-                if self.is_azure_devops() and len(repo_parts) >= 3:
-                    # ADO: org/project/repo/subdir
-                    result = apm_modules_dir / repo_parts[0] / repo_parts[1] / repo_parts[2] / self.virtual_path
-                elif len(repo_parts) >= 2:
-                    # owner/repo/subdir or group/subgroup/repo/subdir
-                    result = apm_modules_dir.joinpath(*repo_parts, self.virtual_path)
-            else:
-                # Virtual file/collection: use sanitized package name (flattened)
-                package_name = self.get_virtual_package_name()
-                if self.is_azure_devops() and len(repo_parts) >= 3:
-                    # ADO: org/project/virtual-pkg-name
-                    result = apm_modules_dir / repo_parts[0] / repo_parts[1] / package_name
-                elif len(repo_parts) >= 2:
-                    # owner/virtual-pkg-name (use first segment as namespace)
-                    result = apm_modules_dir / repo_parts[0] / package_name
-        else:
-            # Regular package: use full repo path
-            if self.is_azure_devops() and len(repo_parts) >= 3:
-                # ADO: org/project/repo
-                result = apm_modules_dir / repo_parts[0] / repo_parts[1] / repo_parts[2]
-            elif len(repo_parts) >= 2:
-                # owner/repo or group/subgroup/repo (generic hosts)
-                result = apm_modules_dir.joinpath(*repo_parts)
-        
-        if result is None:
-            # Fallback: join all parts
-            result = apm_modules_dir.joinpath(*repo_parts)
-
-        # Security: ensure the computed path stays within apm_modules/
-        ensure_path_within(result, apm_modules_dir)
-        return result
-    
-    @staticmethod
-    def _normalize_ssh_protocol_url(url: str) -> str:
-        """Normalize ssh:// protocol URLs to git@ format for consistent parsing.
-        
-        Converts:
-        - ssh://git@gitlab.com/owner/repo.git -> git@gitlab.com:owner/repo.git
-        - ssh://git@host:port/owner/repo.git -> git@host:owner/repo.git
-        
-        Non-SSH URLs are returned unchanged.
-        """
-        if not url.startswith('ssh://'):
-            return url
-        
-        # Parse the ssh:// URL
-        # Format: ssh://[user@]host[:port]/path
-        remainder = url[6:]  # Remove 'ssh://'
-        
-        # Extract user if present (typically 'git@')
-        user_prefix = ""
-        if '@' in remainder.split('/')[0]:
-            user_at_idx = remainder.index('@')
-            user_prefix = remainder[:user_at_idx + 1]  # e.g., "git@"
-            remainder = remainder[user_at_idx + 1:]
-        
-        # Extract host (and optional port)
-        slash_idx = remainder.find('/')
-        if slash_idx == -1:
-            return url  # Invalid format, return as-is
-        
-        host_part = remainder[:slash_idx]
-        path_part = remainder[slash_idx + 1:]
-        
-        # Strip port if present (e.g., host:22)
-        if ':' in host_part:
-            host_part = host_part.split(':')[0]
-        
-        # Convert to git@ format: git@host:path
-        if user_prefix:
-            return f"{user_prefix}{host_part}:{path_part}"
-        else:
-            return f"git@{host_part}:{path_part}"
-
-    @classmethod
-    def parse_from_dict(cls, entry: dict) -> "DependencyReference":
-        """Parse an object-style dependency entry from apm.yml.
-        
-        Supports the Cargo-inspired object format:
-        
-            - git: https://gitlab.com/acme/coding-standards.git
-              path: instructions/security
-              ref: v2.0
-        
-            - git: git@bitbucket.org:team/rules.git
-              path: prompts/review.prompt.md
-        
-        Also supports local path entries:
-        
-            - path: ./packages/my-shared-skills
-        
-        Args:
-            entry: Dictionary with 'git' or 'path' (required), plus optional fields
-            
-        Returns:
-            DependencyReference: Parsed dependency reference
-            
-        Raises:
-            ValueError: If the entry is missing required fields or has invalid format
-        """
-        # Support dict-form local path: { path: ./local/dir }
-        if 'path' in entry and 'git' not in entry:
-            local = entry['path']
-            if not isinstance(local, str) or not local.strip():
-                raise ValueError("'path' field must be a non-empty string")
-            local = local.strip()
-            if not cls.is_local_path(local):
-                raise ValueError(
-                    f"Object-style dependency must have a 'git' field, "
-                    f"or 'path' must be a local filesystem path "
-                    f"(starting with './', '../', '/', or '~')"
-                )
-            return cls.parse(local)
-
-        if 'git' not in entry:
-            raise ValueError("Object-style dependency must have a 'git' or 'path' field")
-        
-        git_url = entry['git']
-        if not isinstance(git_url, str) or not git_url.strip():
-            raise ValueError("'git' field must be a non-empty string")
-        
-        sub_path = entry.get('path')
-        ref_override = entry.get('ref')
-        alias_override = entry.get('alias')
-        
-        # Validate sub_path if provided
-        if sub_path is not None:
-            if not isinstance(sub_path, str) or not sub_path.strip():
-                raise ValueError("'path' field must be a non-empty string")
-            # Normalize backslashes to forward slashes for cross-platform safety
-            sub_path = sub_path.replace('\\', '/').strip().strip('/')
-            # Security: reject path traversal
-            if any(seg in ('.', '..') for seg in sub_path.split('/')):
-                raise PathTraversalError(
-                    f"Invalid path '{sub_path}': path segments must not be '.' or '..'"
-                )
-        
-        # Parse the git URL using the standard parser
-        dep = cls.parse(git_url)
-        
-        # Apply overrides from the object fields
-        if ref_override is not None:
-            if not isinstance(ref_override, str) or not ref_override.strip():
-                raise ValueError("'ref' field must be a non-empty string")
-            dep.reference = ref_override.strip()
-        
-        if alias_override is not None:
-            if not isinstance(alias_override, str) or not alias_override.strip():
-                raise ValueError("'alias' field must be a non-empty string")
-            alias_override = alias_override.strip()
-            if not re.match(r'^[a-zA-Z0-9._-]+$', alias_override):
-                raise ValueError(f"Invalid alias: {alias_override}. Aliases can only contain letters, numbers, dots, underscores, and hyphens")
-            dep.alias = alias_override
-        
-        # Apply sub-path as virtual package
-        if sub_path:
-            dep.virtual_path = sub_path
-            dep.is_virtual = True
-        
-        return dep
-
-    @classmethod
-    def parse(cls, dependency_str: str) -> "DependencyReference":
-        """Parse a dependency string into a DependencyReference.
-        
-        Supports formats:
-        - user/repo
-        - user/repo#branch
-        - user/repo#v1.0.0
-        - user/repo#commit_sha
-        - github.com/user/repo#ref
-        - user/repo/path/to/file.prompt.md (virtual file package)
-        - user/repo/collections/name (virtual collection package)
-        - https://gitlab.com/owner/repo.git (generic HTTPS git URL)
-        - git@gitlab.com:owner/repo.git (SSH git URL)
-        - ssh://git@gitlab.com/owner/repo.git (SSH protocol URL)
-        - ./local/path (local filesystem path)
-        - /absolute/path (local filesystem path)
-        - ../relative/path (local filesystem path)
-        
-        Any valid FQDN is accepted as a git host (GitHub, GitLab, Bitbucket,
-        self-hosted instances, etc.).
-        
-        Args:
-            dependency_str: The dependency string to parse
-            
-        Returns:
-            DependencyReference: Parsed dependency reference
-            
-        Raises:
-            ValueError: If the dependency string format is invalid
-        """
-        if not dependency_str.strip():
-            raise ValueError("Empty dependency string")
-
-        # --- Local path detection (must run before URL/host parsing) ---
-        if cls.is_local_path(dependency_str):
-            local = dependency_str.strip()
-            # Derive a safe directory name from the path basename.
-            # Path("../pkg").name → "pkg", but Path("../").name → "..",
-            # Path("./").name → "", Path("/").name → "" — all unsafe.
-            pkg_name = Path(local).name
-            if not pkg_name or pkg_name in ('.', '..'):
-                raise ValueError(
-                    f"Local path '{local}' does not resolve to a named directory. "
-                    f"Use a path that ends with a directory name "
-                    f"(e.g., './my-package' instead of './')."
-                )
-            return cls(
-                repo_url=f"_local/{pkg_name}",
-                is_local=True,
-                local_path=local,
-            )
-
-        # Decode percent-encoded characters (e.g., %20 for spaces in ADO project names)
-        dependency_str = urllib.parse.unquote(dependency_str)
-
-        # Check for control characters (newlines, tabs, etc.)
-        if any(ord(c) < 32 for c in dependency_str):
-            raise ValueError("Dependency string contains invalid control characters")
-        
-        # SECURITY: Reject protocol-relative URLs (//example.com)
-        if dependency_str.startswith('//'):
-            raise ValueError(unsupported_host_error("//...", context="Protocol-relative URLs are not supported"))
-        
-        # Normalize ssh:// protocol URLs to git@ format
-        dependency_str = cls._normalize_ssh_protocol_url(dependency_str)
-        
-        # Early detection of virtual packages (3+ path segments)
-        # Extract the core path before processing reference (#) and alias (@)
-        work_str = dependency_str
-        
-        # Temporarily remove reference for path segment counting
-        temp_str = work_str
-        if '#' in temp_str:
-            temp_str = temp_str.rsplit('#', 1)[0]
-        
-        # Check if this looks like a virtual package (3+ path segments)
-        # Skip SSH URLs (git@host:owner/repo format)
-        is_virtual_package = False
-        virtual_path = None
-        validated_host = None  # Track if we validated a GitHub hostname
-        
-        if not temp_str.startswith(('git@', 'https://', 'http://')):
-            # SECURITY: Use proper URL parsing instead of substring checks to validate hostnames
-            # This prevents bypasses like "evil.com/github.com/repo" or "github.com.evil.com/repo"
-            check_str = temp_str
-            
-            # Try to parse as potential URL with host prefix
-            if '/' in check_str:
-                first_segment = check_str.split('/')[0]
-                
-                # If first segment contains a dot, it might be a hostname - VALIDATE IT
-                if '.' in first_segment:
-                    # Construct a full URL and parse it properly
-                    test_url = f"https://{check_str}"
-                    try:
-                        parsed = urllib.parse.urlparse(test_url)
-                        hostname = parsed.hostname
-                        
-                        # SECURITY CRITICAL: If there's a dot in first segment, it MUST be a valid Git hostname
-                        # Otherwise reject it - prevents evil-github.com, github.com.evil.com attacks
-                        if hostname and is_supported_git_host(hostname):
-                            # Valid Git hosting hostname - extract path after it
-                            validated_host = hostname
-                            path_parts = parsed.path.lstrip('/').split('/')
-                            if len(path_parts) >= 2:
-                                # Remove the hostname from check_str by taking everything after first segment
-                                check_str = '/'.join(check_str.split('/')[1:])
-                        else:
-                            # First segment has a dot but is NOT a valid Git host - REJECT
-                            raise ValueError(
-                                unsupported_host_error(hostname or first_segment)
-                            )
-                    except (ValueError, AttributeError) as e:
-                        # If we can't parse or validate, and first segment has dot, it's suspicious - REJECT
-                        if isinstance(e, ValueError) and "Invalid Git host" in str(e):
-                            raise  # Re-raise our security error
-                        raise ValueError(
-                            unsupported_host_error(first_segment)
-                        )
-                elif check_str.startswith('gh/'):
-                    # Handle 'gh/' shorthand - only if it's exactly at the start
-                    check_str = '/'.join(check_str.split('/')[1:])
-            
-            # Count segments (owner/repo/path/to/file = 5 segments)
-            path_segments = check_str.split('/')
-            
-            # Filter out empty segments (from double slashes like "user//repo")
-            path_segments = [seg for seg in path_segments if seg]
-            
-            # For Azure DevOps, the base package format is org/project/repo (3 segments)
-            # Virtual packages would have 4+ segments: org/project/repo/path/to/file
-            # For GitHub, base is owner/repo (2 segments), virtual is 3+ segments
-            # For generic hosts (GitLab, Gitea, etc.), all segments are repo path
-            # unless virtual indicators (file extensions, collections) are present
-            is_ado = validated_host is not None and is_azure_devops_hostname(validated_host)
-            is_generic_host = (validated_host is not None
-                               and not is_github_hostname(validated_host)
-                               and not is_azure_devops_hostname(validated_host))
-
-            # Detect Artifactory VCS paths (artifactory/{repo-key}/{owner}/{repo})
-            is_artifactory = is_generic_host and is_artifactory_path(path_segments)
-
-            # Handle _git in ADO URLs: org/project/_git/repo -> org/project/repo
-            if is_ado and '_git' in path_segments:
-                git_idx = path_segments.index('_git')
-                # Remove _git from the path segments
-                path_segments = path_segments[:git_idx] + path_segments[git_idx+1:]
-            
-            if is_ado:
-                min_base_segments = 3
-            elif is_artifactory:
-                # Artifactory: artifactory/{repo-key}/{owner}/{repo}
-                min_base_segments = 4
-            elif is_generic_host:
-                # For generic hosts (GitLab, Gitea), check for virtual indicators
-                # If present, use 2-segment base (simple owner/repo + virtual path)
-                # If absent, treat ALL segments as the repo path (nested groups)
-                has_virtual_ext = any(
-                    any(seg.endswith(ext) for ext in cls.VIRTUAL_FILE_EXTENSIONS)
-                    for seg in path_segments
-                )
-                has_collection = 'collections' in path_segments
-                if has_virtual_ext or has_collection:
-                    min_base_segments = 2  # Simple repo with virtual path
-                else:
-                    min_base_segments = len(path_segments)  # All segments = repo path
-            else:
-                min_base_segments = 2  # GitHub: owner/repo
-            min_virtual_segments = min_base_segments + 1
-            
-            if len(path_segments) >= min_virtual_segments:
-                # This is a virtual package!
-                # For GitHub: owner/repo/path/to/file.prompt.md
-                # For ADO: org/project/repo/path/to/file.prompt.md
-                is_virtual_package = True
-                
-                # Extract virtual path (base repo is derived later)
-                virtual_path = '/'.join(path_segments[min_base_segments:])
-
-                # Security: reject path traversal in virtual path
-                # Normalize backslashes for cross-platform safety
-                vp_check = virtual_path.replace('\\', '/')
-                if any(seg in ('.', '..') for seg in vp_check.split('/')):
-                    raise PathTraversalError(
-                        f"Invalid virtual path '{virtual_path}': path segments must not be '.' or '..'"
-                    )
-                
-                # Virtual package types (validated later during download):
-                # 1. Collections: /collections/ in path
-                # 2. Individual files: ends with .prompt.md, .agent.md, etc.
-                # 3. Subdirectory packages: directory path (may contain apm.yml or SKILL.md)
-                #    This allows Claude Skills and nested APM packages in monorepos
-                if '/collections/' in check_str or virtual_path.startswith('collections/'):
-                    # Collection virtual package - validated by fetching .collection.yml
-                    pass
-                elif any(virtual_path.endswith(ext) for ext in cls.VIRTUAL_FILE_EXTENSIONS):
-                    # Individual file virtual package - valid extension
-                    pass
-                else:
-                    # Check if it looks like a file (has extension) vs directory
-                    last_segment = virtual_path.split('/')[-1]
-                    if '.' in last_segment:
-                        # Looks like a file with unknown extension - reject
-                        raise InvalidVirtualPackageExtensionError(
-                            f"Invalid virtual package path '{virtual_path}'. "
-                            f"Individual files must end with one of: {', '.join(cls.VIRTUAL_FILE_EXTENSIONS)}. "
-                            f"For subdirectory packages, the path should not have a file extension."
-                        )
-                    # Subdirectory package - will be validated by checking for apm.yml or SKILL.md
-        
-        # Handle SSH URLs first (before @ processing) to avoid conflict with alias separator
-        original_str = dependency_str
-        ssh_repo_part = None
-        host = None
-        # Match patterns like git@host:owner/repo.git
-        ssh_match = re.match(r'^git@([^:]+):(.+)$', dependency_str)
-        if ssh_match:
-            host = ssh_match.group(1)
-            ssh_repo_part = ssh_match.group(2)
-
-            # Handle reference in SSH URL (extract before .git stripping)
-            reference = None
-            alias = None
-
-            if "#" in ssh_repo_part:
-                repo_part, reference = ssh_repo_part.rsplit("#", 1)
-                reference = reference.strip()
-            else:
-                repo_part = ssh_repo_part
-
-            # Strip .git suffix after extracting ref and alias
-            if repo_part.endswith('.git'):
-                repo_part = repo_part[:-4]
-
-            repo_url = repo_part.strip()
-        else:
-            # Handle reference (#ref)
-            alias = None
-            reference = None
-            if "#" in dependency_str:
-                repo_part, reference = dependency_str.rsplit("#", 1)
-                reference = reference.strip()
-            else:
-                repo_part = dependency_str
-            
-            # SECURITY: Use urllib.parse for all URL validation to avoid substring vulnerabilities
-            
-            repo_url = repo_part.strip()
-            
-            # For virtual packages, extract just the owner/repo part (or org/project/repo for ADO)
-            if is_virtual_package and not repo_url.startswith(("https://", "http://")):
-                # Virtual packages have format: owner/repo/path/to/file or host/owner/repo/path/to/file
-                # For ADO: dev.azure.com/org/project/repo/path/to/file (4+ with host) or org/project/repo/path (3+ without host)
-                parts = repo_url.split("/")
-                
-                # Handle _git in path: org/project/_git/repo -> org/project/repo
-                if '_git' in parts:
-                    git_idx = parts.index('_git')
-                    parts = parts[:git_idx] + parts[git_idx+1:]
-                
-                # Check if starts with host
-                if len(parts) >= 3 and is_supported_git_host(parts[0]):
-                    host = parts[0]
-                    # For ADO: dev.azure.com/org/project/repo/path -> extract org/project/repo
-                    # For GitHub: github.com/owner/repo/path -> extract owner/repo
-                    if is_azure_devops_hostname(parts[0]):
-                        if len(parts) < 5:  # host + org + project + repo + at least one path segment
-                            raise ValueError("Invalid Azure DevOps virtual package format: must be dev.azure.com/org/project/repo/path")
-                        repo_url = "/".join(parts[1:4])  # org/project/repo
-                    elif is_artifactory_path(parts[1:]):
-                        art_result = parse_artifactory_path(parts[1:])
-                        if art_result:
-                            repo_url = f"{art_result[1]}/{art_result[2]}"
-                    else:
-                        # For virtual packages with host prefix, base is always 2 segments
-                        # (virtual indicators already detected in early detection)
-                        repo_url = "/".join(parts[1:3])  # owner/repo
-                elif len(parts) >= 2:
-                    # No host prefix
-                    if not host:
-                        host = default_host()
-                    # Use validated_host to check if this is ADO
-                    if validated_host and is_azure_devops_hostname(validated_host):
-                        if len(parts) < 4:  # org + project + repo + at least one path segment
-                            raise ValueError("Invalid Azure DevOps virtual package format: expected at least org/project/repo/path")
-                        repo_url = "/".join(parts[:3])  # org/project/repo
-                    elif is_artifactory_path(parts[1:]):
-                        art_result = parse_artifactory_path(parts[1:])
-                        if art_result:
-                            repo_url = f"{art_result[1]}/{art_result[2]}"
-                    else:
-                        repo_url = "/".join(parts[:2])  # owner/repo
-            
-            # Normalize to URL format for secure parsing - always use urllib.parse, never substring checks
-            if repo_url.startswith(("https://", "http://")):
-                # Already a full URL - parse directly
-                parsed_url = urllib.parse.urlparse(repo_url)
-                host = parsed_url.hostname or ""
-            else:
-                # Safely construct a URL from various input formats. Support GitHub, GitHub Enterprise,
-                # Azure DevOps, and other Git hosting platforms.
-                parts = repo_url.split("/")
-                
-                # Handle _git in path for ADO URLs
-                if '_git' in parts:
-                    git_idx = parts.index('_git')
-                    parts = parts[:git_idx] + parts[git_idx+1:]
-                
-                # host/user/repo  OR user/repo (no host)
-                if len(parts) >= 3 and is_supported_git_host(parts[0]):
-                    # Format with host prefix: github.com/user/repo OR dev.azure.com/org/project/repo
-                    host = parts[0]
-                    if is_azure_devops_hostname(host) and len(parts) >= 4:
-                        # ADO format: dev.azure.com/org/project/repo
-                        user_repo = "/".join(parts[1:4])
-                    elif not is_github_hostname(host) and not is_azure_devops_hostname(host):
-                        if is_artifactory_path(parts[1:]):
-                            art_result = parse_artifactory_path(parts[1:])
-                            if art_result:
-                                user_repo = f"{art_result[1]}/{art_result[2]}"
-                            else:
-                                user_repo = "/".join(parts[1:])
-                        else:
-                            user_repo = "/".join(parts[1:])
-                    else:
-                        # GitHub format: github.com/user/repo
-                        user_repo = "/".join(parts[1:3])
-                elif len(parts) >= 2 and "." not in parts[0]:
-                    # Format without host: user/repo or org/project/repo (for ADO)
-                    if not host:
-                        host = default_host()
-                    # Check if default host is ADO
-                    if is_azure_devops_hostname(host) and len(parts) >= 3:
-                        user_repo = "/".join(parts[:3])  # org/project/repo
-                    elif host and not is_github_hostname(host) and not is_azure_devops_hostname(host):
-                        # Generic host: all segments = repo path
-                        user_repo = "/".join(parts)
-                    else:
-                        user_repo = "/".join(parts[:2])  # user/repo
-                else:
-                    raise ValueError(f"Use 'user/repo' or 'github.com/user/repo' or 'dev.azure.com/org/project/repo' format")
-
-                # Validate format before URL construction (security critical)
-                if not user_repo or "/" not in user_repo:
-                    raise ValueError(f"Invalid repository format: {repo_url}. Expected 'user/repo' or 'org/project/repo'")
-
-                uparts = user_repo.split("/")
-                is_ado_host = host and is_azure_devops_hostname(host)
-                
-                if is_ado_host:
-                    if len(uparts) < 3:
-                        raise ValueError(f"Invalid Azure DevOps repository format: {repo_url}. Expected 'org/project/repo'")
-                else:
-                    if len(uparts) < 2:
-                        raise ValueError(f"Invalid repository format: {repo_url}. Expected 'user/repo'")
-                
-                # Security: validate characters to prevent injection
-                # ADO project names may contain spaces
-                allowed_pattern = r'^[a-zA-Z0-9._\- ]+$' if is_ado_host else r'^[a-zA-Z0-9._-]+$'
-                for part in uparts:
-                    if part in ('.', '..'):
-                        raise PathTraversalError(
-                            f"Invalid repository path component: '{part}' is a traversal sequence"
-                        )
-                    if not re.match(allowed_pattern, part.rstrip('.git')):
-                        raise ValueError(f"Invalid repository path component: {part}")
-
-                # Safely construct URL using detected host
-                # Quote path components to handle spaces in ADO project names
-                quoted_repo = '/'.join(urllib.parse.quote(p, safe='') for p in uparts)
-                github_url = urllib.parse.urljoin(f"https://{host}/", quoted_repo)
-                parsed_url = urllib.parse.urlparse(github_url)
-
-            # SECURITY: Validate that this is actually a supported Git host URL.
-            # Accept github.com, GitHub Enterprise, Azure DevOps, etc. Use parsed_url.hostname
-            hostname = parsed_url.hostname or ""
-            if not is_supported_git_host(hostname):
-                raise ValueError(unsupported_host_error(hostname or parsed_url.netloc))
-            
-            # Extract and validate the path
-            path = parsed_url.path.strip("/")
-            if not path:
-                raise ValueError("Repository path cannot be empty")
-            
-            # Remove .git suffix if present
-            if path.endswith(".git"):
-                path = path[:-4]
-            
-            # Handle _git in parsed path for ADO URLs
-            # Decode percent-encoded path components (e.g., spaces in ADO project names)
-            path_parts = [urllib.parse.unquote(p) for p in path.split("/")]
-            if '_git' in path_parts:
-                git_idx = path_parts.index('_git')
-                path_parts = path_parts[:git_idx] + path_parts[git_idx+1:]
-
-            # Validate path format based on host type
-            is_ado_host = is_azure_devops_hostname(hostname)
-
-            if is_ado_host:
-                if len(path_parts) != 3:
-                    raise ValueError(f"Invalid Azure DevOps repository path: expected 'org/project/repo', got '{path}'")
-            else:
-                if len(path_parts) < 2:
-                    raise ValueError(f"Invalid repository path: expected at least 'user/repo', got '{path}'")
-                # HTTPS URLs cannot embed virtual paths  -- reject virtual file extensions
-                for pp in path_parts:
-                    if any(pp.endswith(ext) for ext in cls.VIRTUAL_FILE_EXTENSIONS):
-                        raise ValueError(
-                            f"Invalid repository path: '{path}' contains a virtual file extension. "
-                            f"Use the dict format with 'path:' for virtual packages in HTTPS URLs"
-                        )
-
-            # Validate all path parts contain only allowed characters
-            # ADO project names may contain spaces
-            allowed_pattern = r'^[a-zA-Z0-9._\- ]+$' if is_ado_host else r'^[a-zA-Z0-9._-]+$'
-            for i, part in enumerate(path_parts):
-                if not part:
-                    raise ValueError(f"Invalid repository format: path component {i+1} cannot be empty")
-                if not re.match(allowed_pattern, part):
-                    raise ValueError(f"Invalid repository path component: {part}")
-
-            repo_url = "/".join(path_parts)
-            
-            # If host not set via SSH or parsed parts, default to default_host()
-            if not host:
-                host = default_host()
-
-        
-        # Validate repo format based on host type
-        is_ado_final = host and is_azure_devops_hostname(host)
-        if is_ado_final:
-            # ADO format: org/project/repo (3 segments, project may contain spaces)
-            if not re.match(r'^[a-zA-Z0-9._-]+/[a-zA-Z0-9._\- ]+/[a-zA-Z0-9._-]+$', repo_url):
-                raise ValueError(f"Invalid Azure DevOps repository format: {repo_url}. Expected 'org/project/repo'")
-            # Extract ADO-specific fields
-            ado_parts = repo_url.split('/')
-            ado_organization = ado_parts[0]
-            ado_project = ado_parts[1]
-            ado_repo = ado_parts[2]
-        else:
-            # Non-ADO format: user/repo or group/subgroup/repo (2+ segments)
-            segments = repo_url.split('/')
-            if len(segments) < 2:
-                raise ValueError(f"Invalid repository format: {repo_url}. Expected 'user/repo'")
-            if not all(re.match(r'^[a-zA-Z0-9._-]+$', s) for s in segments):
-                raise ValueError(f"Invalid repository format: {repo_url}. Contains invalid characters")
-            # SSH/HTTPS URLs cannot embed virtual paths  -- reject virtual file extensions
-            for seg in segments:
-                if any(seg.endswith(ext) for ext in cls.VIRTUAL_FILE_EXTENSIONS):
-                    raise ValueError(
-                        f"Invalid repository format: '{repo_url}' contains a virtual file extension. "
-                        f"Use the dict format with 'path:' for virtual packages in SSH/HTTPS URLs"
-                    )
-            ado_organization = None
-            ado_project = None
-            ado_repo = None
-        
-        # Extract Artifactory prefix from the original path if applicable
-        artifactory_prefix = None
-        if host and not is_ado_final:
-            _art_str = original_str.split('#')[0].split('@')[0]
-            # Strip scheme if present (e.g., https://host/artifactory/...)
-            if '://' in _art_str:
-                _art_str = _art_str.split('://', 1)[1]
-            _art_segs = _art_str.replace(f"{host}/", "", 1).split("/")
-            if is_artifactory_path(_art_segs):
-                art_result = parse_artifactory_path(_art_segs)
-                if art_result:
-                    artifactory_prefix = art_result[0]
-
-        return cls(
-            repo_url=repo_url,
-            host=host,
-            reference=reference,
-            alias=alias,
-            virtual_path=virtual_path,
-            is_virtual=is_virtual_package,
-            ado_organization=ado_organization,
-            ado_project=ado_project,
-            ado_repo=ado_repo,
-            artifactory_prefix=artifactory_prefix
-        )
-
-    def to_github_url(self) -> str:
-        """Convert to full repository URL.
-        
-        For Azure DevOps, generates: https://dev.azure.com/org/project/_git/repo
-        For GitHub, generates: https://github.com/owner/repo
-        For local packages, returns the local path.
-        """
-        if self.is_local and self.local_path:
-            return self.local_path
-
-        host = self.host or default_host()
-        
-        if self.is_azure_devops():
-            # ADO format: https://dev.azure.com/org/project/_git/repo
-            project = urllib.parse.quote(self.ado_project, safe='')
-            return f"https://{host}/{self.ado_organization}/{project}/_git/{self.ado_repo}"
-        elif self.is_artifactory():
-            return f"https://{host}/{self.artifactory_prefix}/{self.repo_url}"
-        else:
-            # GitHub format: https://github.com/owner/repo
-            return f"https://{host}/{self.repo_url}"
-    
-    def to_clone_url(self) -> str:
-        """Convert to a clone-friendly URL (same as to_github_url for most purposes)."""
-        return self.to_github_url()
-
-    def get_display_name(self) -> str:
-        """Get display name for this dependency (alias or repo name)."""
-        if self.alias:
-            return self.alias
-        if self.is_local and self.local_path:
-            return self.local_path
-        if self.is_virtual:
-            return self.get_virtual_package_name()
-        return self.repo_url  # Full repo URL for disambiguation
-
-    def __str__(self) -> str:
-        """String representation of the dependency reference."""
-        if self.is_local and self.local_path:
-            return self.local_path
-        if self.host:
-            if self.artifactory_prefix:
-                result = f"{self.host}/{self.artifactory_prefix}/{self.repo_url}"
-            else:
-                result = f"{self.host}/{self.repo_url}"
-        else:
-            result = self.repo_url
-        if self.virtual_path:
-            result += f"/{self.virtual_path}"
-        if self.reference:
-            result += f"#{self.reference}"
-        return result
-
-
-@dataclass
-class MCPDependency:
-    """Represents an MCP server dependency with optional overlay configuration.
-
-    Supports three forms:
-    - String (registry reference): MCPDependency.from_string("io.github.github/github-mcp-server")
-    - Object with overlays: MCPDependency.from_dict({"name": "...", "transport": "stdio", ...})
-    - Self-defined (registry: false): MCPDependency.from_dict({"name": "...", "registry": False, "transport": "http", "url": "..."})
-    """
-    name: str
-    transport: Optional[str] = None          # "stdio" | "sse" | "streamable-http" | "http"
-    env: Optional[Dict[str, str]] = None     # Environment variable overrides
-    args: Optional[Any] = None               # Dict for overlay variable overrides, List for self-defined positional args
-    version: Optional[str] = None            # Pin specific server version
-    registry: Optional[Any] = None           # None=default, False=self-defined, str=custom registry URL
-    package: Optional[str] = None            # "npm" | "pypi" | "oci"  -- select package type
-    headers: Optional[Dict[str, str]] = None # Custom HTTP headers for remote endpoints
-    tools: Optional[List[str]] = None        # Restrict exposed tools (default is ["*"])
-    url: Optional[str] = None                # Required for self-defined http/sse transports
-    command: Optional[str] = None            # Required for self-defined stdio transports
-
-    @classmethod
-    def from_string(cls, s: str) -> "MCPDependency":
-        """Create an MCPDependency from a plain string (registry reference)."""
-        return cls(name=s)
-
-    @classmethod
-    def from_dict(cls, d: dict) -> "MCPDependency":
-        """Parse an MCPDependency from a dict.
-
-        Handles backward compatibility: 'type' key is mapped to 'transport'.
-        Unknown keys are silently ignored for forward compatibility.
-        """
-        if 'name' not in d:
-            raise ValueError("MCP dependency dict must contain 'name'")
-
-        transport = d.get('transport') or d.get('type')  # legacy 'type' -> 'transport'
-
-        instance = cls(
-            name=d['name'],
-            transport=transport,
-            env=d.get('env'),
-            args=d.get('args'),
-            version=d.get('version'),
-            registry=d.get('registry'),
-            package=d.get('package'),
-            headers=d.get('headers'),
-            tools=d.get('tools'),
-            url=d.get('url'),
-            command=d.get('command'),
-        )
-
-        if instance.registry is False:
-            instance.validate()
-
-        return instance
-
-    @property
-    def is_registry_resolved(self) -> bool:
-        """True when the dependency is resolved via a registry."""
-        return self.registry is not False
-
-    @property
-    def is_self_defined(self) -> bool:
-        """True when the dependency is self-defined (registry: false)."""
-        return self.registry is False
-
-    def to_dict(self) -> dict:
-        """Serialize to dict, including only non-None fields."""
-        result: Dict[str, Any] = {'name': self.name}
-        for field_name in ('transport', 'env', 'args', 'version', 'registry',
-                           'package', 'headers', 'tools', 'url', 'command'):
-            value = getattr(self, field_name)
-            if value is not None or (field_name == 'registry' and value is False):
-                result[field_name] = value
-        return result
-
-    _VALID_TRANSPORTS = frozenset({"stdio", "sse", "http", "streamable-http"})
-
-    def __str__(self) -> str:
-        """Return a redacted, human-friendly identifier for logging and CLI output."""
-        if self.transport:
-            return f"{self.name} ({self.transport})"
-        return self.name
-
-    def __repr__(self) -> str:
-        """Return a redacted representation to keep secrets out of debug logs."""
-        parts = [f"name={self.name!r}"]
-        if self.transport:
-            parts.append(f"transport={self.transport!r}")
-        if self.env:
-            safe_env = {k: '***' for k in self.env}
-            parts.append(f"env={safe_env}")
-        if self.headers:
-            safe_headers = {k: '***' for k in self.headers}
-            parts.append(f"headers={safe_headers}")
-        if self.args is not None:
-            parts.append("args=...")
-        if self.tools:
-            parts.append(f"tools={self.tools!r}")
-        if self.url:
-            parts.append(f"url={self.url!r}")
-        if self.command:
-            parts.append(f"command={self.command!r}")
-        return f"MCPDependency({', '.join(parts)})"
-
-    def validate(self) -> None:
-        """Validate the dependency. Raises ValueError on invalid state."""
-        if not self.name:
-            raise ValueError("MCP dependency 'name' must not be empty")
-        if self.transport and self.transport not in self._VALID_TRANSPORTS:
-            raise ValueError(
-                f"MCP dependency '{self.name}' has unsupported transport "
-                f"'{self.transport}'. Valid values: {', '.join(sorted(self._VALID_TRANSPORTS))}"
-            )
-        if self.registry is False:
-            if not self.transport:
-                raise ValueError(
-                    f"Self-defined MCP dependency '{self.name}' requires 'transport'"
-                )
-            if self.transport in ('http', 'sse', 'streamable-http') and not self.url:
-                raise ValueError(
-                    f"Self-defined MCP dependency '{self.name}' with transport "
-                    f"'{self.transport}' requires 'url'"
-                )
-            if self.transport == 'stdio' and not self.command:
-                raise ValueError(
-                    f"Self-defined MCP dependency '{self.name}' with transport "
-                    f"'stdio' requires 'command'"
-                )
-
-
-def parse_git_reference(ref_string: str) -> tuple[GitReferenceType, str]:
-    """Parse a git reference string to determine its type.
-    
-    Args:
-        ref_string: Git reference (branch, tag, or commit)
-        
-    Returns:
-        tuple: (GitReferenceType, cleaned_reference)
-    """
-    if not ref_string:
-        return GitReferenceType.BRANCH, "main"  # Default to main branch
-    
-    ref = ref_string.strip()
-    
-    # Check if it looks like a commit SHA (40 hex chars or 7+ hex chars)
-    if re.match(r'^[a-f0-9]{7,40}$', ref.lower()):
-        return GitReferenceType.COMMIT, ref
-    
-    # Check if it looks like a semantic version tag
-    if re.match(r'^v?\d+\.\d+\.\d+', ref):
-        return GitReferenceType.TAG, ref
-    
-    # Otherwise assume it's a branch
-    return GitReferenceType.BRANCH, ref
diff --git a/src/apm_cli/models/dependency/__init__.py b/src/apm_cli/models/dependency/__init__.py
new file mode 100644
index 000000000..edab860c4
--- /dev/null
+++ b/src/apm_cli/models/dependency/__init__.py
@@ -0,0 +1,14 @@
+"""Dependency reference models and Git reference utilities."""
+
+from .mcp import MCPDependency
+from .reference import DependencyReference
+from .types import GitReferenceType, ResolvedReference, VirtualPackageType, parse_git_reference
+
+__all__ = [
+    "DependencyReference",
+    "GitReferenceType",
+    "MCPDependency",
+    "ResolvedReference",
+    "VirtualPackageType",
+    "parse_git_reference",
+]
diff --git a/src/apm_cli/models/dependency/mcp.py b/src/apm_cli/models/dependency/mcp.py
new file mode 100644
index 000000000..ee461dd1e
--- /dev/null
+++ b/src/apm_cli/models/dependency/mcp.py
@@ -0,0 +1,136 @@
+"""MCP dependency model."""
+
+from dataclasses import dataclass
+from typing import Any, Dict, List, Optional
+
+
+@dataclass
+class MCPDependency:
+    """Represents an MCP server dependency with optional overlay configuration.
+
+    Supports three forms:
+    - String (registry reference): MCPDependency.from_string("io.github.github/github-mcp-server")
+    - Object with overlays: MCPDependency.from_dict({"name": "...", "transport": "stdio", ...})
+    - Self-defined (registry: false): MCPDependency.from_dict({"name": "...", "registry": False, "transport": "http", "url": "..."})
+    """
+    name: str
+    transport: Optional[str] = None          # "stdio" | "sse" | "streamable-http" | "http"
+    env: Optional[Dict[str, str]] = None     # Environment variable overrides
+    args: Optional[Any] = None               # Dict for overlay variable overrides, List for self-defined positional args
+    version: Optional[str] = None            # Pin specific server version
+    registry: Optional[Any] = None           # None=default, False=self-defined, str=custom registry URL
+    package: Optional[str] = None            # "npm" | "pypi" | "oci" — select package type
+    headers: Optional[Dict[str, str]] = None # Custom HTTP headers for remote endpoints
+    tools: Optional[List[str]] = None        # Restrict exposed tools (default is ["*"])
+    url: Optional[str] = None                # Required for self-defined http/sse transports
+    command: Optional[str] = None            # Required for self-defined stdio transports
+
+    @classmethod
+    def from_string(cls, s: str) -> "MCPDependency":
+        """Create an MCPDependency from a plain string (registry reference)."""
+        return cls(name=s)
+
+    @classmethod
+    def from_dict(cls, d: dict) -> "MCPDependency":
+        """Parse an MCPDependency from a dict.
+
+        Handles backward compatibility: 'type' key is mapped to 'transport'.
+        Unknown keys are silently ignored for forward compatibility.
+        """
+        if 'name' not in d:
+            raise ValueError("MCP dependency dict must contain 'name'")
+
+        transport = d.get('transport') or d.get('type')  # legacy 'type' -> 'transport'
+
+        instance = cls(
+            name=d['name'],
+            transport=transport,
+            env=d.get('env'),
+            args=d.get('args'),
+            version=d.get('version'),
+            registry=d.get('registry'),
+            package=d.get('package'),
+            headers=d.get('headers'),
+            tools=d.get('tools'),
+            url=d.get('url'),
+            command=d.get('command'),
+        )
+
+        if instance.registry is False:
+            instance.validate()
+
+        return instance
+
+    @property
+    def is_registry_resolved(self) -> bool:
+        """True when the dependency is resolved via a registry."""
+        return self.registry is not False
+
+    @property
+    def is_self_defined(self) -> bool:
+        """True when the dependency is self-defined (registry: false)."""
+        return self.registry is False
+
+    def to_dict(self) -> dict:
+        """Serialize to dict, including only non-None fields."""
+        result: Dict[str, Any] = {'name': self.name}
+        for field_name in ('transport', 'env', 'args', 'version', 'registry',
+                           'package', 'headers', 'tools', 'url', 'command'):
+            value = getattr(self, field_name)
+            if value is not None or (field_name == 'registry' and value is False):
+                result[field_name] = value
+        return result
+
+    _VALID_TRANSPORTS = frozenset({"stdio", "sse", "http", "streamable-http"})
+
+    def __str__(self) -> str:
+        """Return a redacted, human-friendly identifier for logging and CLI output."""
+        if self.transport:
+            return f"{self.name} ({self.transport})"
+        return self.name
+
+    def __repr__(self) -> str:
+        """Return a redacted representation to keep secrets out of debug logs."""
+        parts = [f"name={self.name!r}"]
+        if self.transport:
+            parts.append(f"transport={self.transport!r}")
+        if self.env:
+            safe_env = {k: '***' for k in self.env}
+            parts.append(f"env={safe_env}")
+        if self.headers:
+            safe_headers = {k: '***' for k in self.headers}
+            parts.append(f"headers={safe_headers}")
+        if self.args is not None:
+            parts.append("args=...")
+        if self.tools:
+            parts.append(f"tools={self.tools!r}")
+        if self.url:
+            parts.append(f"url={self.url!r}")
+        if self.command:
+            parts.append(f"command={self.command!r}")
+        return f"MCPDependency({', '.join(parts)})"
+
+    def validate(self) -> None:
+        """Validate the dependency. Raises ValueError on invalid state."""
+        if not self.name:
+            raise ValueError("MCP dependency 'name' must not be empty")
+        if self.transport and self.transport not in self._VALID_TRANSPORTS:
+            raise ValueError(
+                f"MCP dependency '{self.name}' has unsupported transport "
+                f"'{self.transport}'. Valid values: {', '.join(sorted(self._VALID_TRANSPORTS))}"
+            )
+        if self.registry is False:
+            if not self.transport:
+                raise ValueError(
+                    f"Self-defined MCP dependency '{self.name}' requires 'transport'"
+                )
+            if self.transport in ('http', 'sse', 'streamable-http') and not self.url:
+                raise ValueError(
+                    f"Self-defined MCP dependency '{self.name}' with transport "
+                    f"'{self.transport}' requires 'url'"
+                )
+            if self.transport == 'stdio' and not self.command:
+                raise ValueError(
+                    f"Self-defined MCP dependency '{self.name}' with transport "
+                    f"'stdio' requires 'command'"
+                )
diff --git a/src/apm_cli/models/dependency/reference.py b/src/apm_cli/models/dependency/reference.py
new file mode 100644
index 000000000..55207a9e7
--- /dev/null
+++ b/src/apm_cli/models/dependency/reference.py
@@ -0,0 +1,958 @@
+"""DependencyReference model  -- core dependency representation and parsing."""
+
+import re
+import urllib.parse
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+from ...utils.github_host import (
+    default_host,
+    is_artifactory_path,
+    is_azure_devops_hostname,
+    is_github_hostname,
+    is_supported_git_host,
+    parse_artifactory_path,
+    unsupported_host_error,
+)
+from ...utils.path_security import PathTraversalError, ensure_path_within
+from ..validation import InvalidVirtualPackageExtensionError
+from .types import VirtualPackageType
+
+
+@dataclass 
+class DependencyReference:
+    """Represents a reference to an APM dependency."""
+    repo_url: str  # e.g., "user/repo" for GitHub or "org/project/repo" for Azure DevOps
+    host: Optional[str] = None  # Optional host (github.com, dev.azure.com, or enterprise host)
+    reference: Optional[str] = None  # e.g., "main", "v1.0.0", "abc123"
+    alias: Optional[str] = None  # Optional alias for the dependency
+    virtual_path: Optional[str] = None  # Path for virtual packages (e.g., "prompts/file.prompt.md")
+    is_virtual: bool = False  # True if this is a virtual package (individual file or collection)
+    
+    # Azure DevOps specific fields (ADO uses org/project/repo structure)
+    ado_organization: Optional[str] = None  # e.g., "dmeppiel-org"
+    ado_project: Optional[str] = None       # e.g., "market-js-app"
+    ado_repo: Optional[str] = None          # e.g., "compliance-rules"
+
+    # Local path dependency fields
+    is_local: bool = False  # True if this is a local filesystem dependency
+    local_path: Optional[str] = None  # Original local path string (e.g., "./packages/my-pkg")
+
+    artifactory_prefix: Optional[str] = None  # e.g., "artifactory/github" (repo key path)
+    
+    # Supported file extensions for virtual packages
+    VIRTUAL_FILE_EXTENSIONS = ('.prompt.md', '.instructions.md', '.chatmode.md', '.agent.md')
+
+    def is_artifactory(self) -> bool:
+        """Check if this reference points to a JFrog Artifactory VCS repository."""
+        return self.artifactory_prefix is not None
+    
+    def is_azure_devops(self) -> bool:
+        """Check if this reference points to Azure DevOps."""
+        from ...utils.github_host import is_azure_devops_hostname
+        return self.host is not None and is_azure_devops_hostname(self.host)
+    
+    @property
+    def virtual_type(self) -> 'Optional[VirtualPackageType]':
+        """Return the type of virtual package, or None if not virtual."""
+        if not self.is_virtual or not self.virtual_path:
+            return None
+        if any(self.virtual_path.endswith(ext) for ext in self.VIRTUAL_FILE_EXTENSIONS):
+            return VirtualPackageType.FILE
+        if '/collections/' in self.virtual_path or self.virtual_path.startswith('collections/'):
+            return VirtualPackageType.COLLECTION
+        return VirtualPackageType.SUBDIRECTORY
+
+    def is_virtual_file(self) -> bool:
+        """Check if this is a virtual file package (individual file)."""
+        return self.virtual_type == VirtualPackageType.FILE
+    
+    def is_virtual_collection(self) -> bool:
+        """Check if this is a virtual collection package."""
+        return self.virtual_type == VirtualPackageType.COLLECTION
+    
+    def is_virtual_subdirectory(self) -> bool:
+        """Check if this is a virtual subdirectory package (e.g., Claude Skill).
+        
+        A subdirectory package is a virtual package that:
+        - Has a virtual_path that is NOT a file extension we recognize
+        - Is NOT a collection (doesn't have /collections/ in path)
+        - Is a directory path (likely containing SKILL.md or apm.yml)
+        
+        Examples:
+            - ComposioHQ/awesome-claude-skills/brand-guidelines → True
+            - owner/repo/prompts/file.prompt.md → False (is_virtual_file)
+            - owner/repo/collections/name → False (is_virtual_collection)
+        """
+        return self.virtual_type == VirtualPackageType.SUBDIRECTORY
+    
+    def get_virtual_package_name(self) -> str:
+        """Generate a package name for this virtual package.
+        
+        For virtual packages, we create a sanitized name from the path:
+        - owner/repo/prompts/code-review.prompt.md → repo-code-review
+        - owner/repo/collections/project-planning → repo-project-planning
+        - owner/repo/collections/project-planning.collection.yml → repo-project-planning
+        """
+        if not self.is_virtual or not self.virtual_path:
+            return self.repo_url.split('/')[-1]  # Return repo name as fallback
+        
+        # Extract repo name and file/collection name
+        repo_parts = self.repo_url.split('/')
+        repo_name = repo_parts[-1] if repo_parts else "package"
+        
+        # Get the basename without extension
+        path_parts = self.virtual_path.split('/')
+        if self.is_virtual_collection():
+            # For collections: use the collection name without extension
+            # collections/project-planning → project-planning
+            # collections/project-planning.collection.yml → project-planning
+            collection_name = path_parts[-1]
+            # Strip .collection.yml/.collection.yaml extension if present
+            for ext in ('.collection.yml', '.collection.yaml'):
+                if collection_name.endswith(ext):
+                    collection_name = collection_name[:-len(ext)]
+                    break
+            return f"{repo_name}-{collection_name}"
+        else:
+            # For individual files: use the filename without extension
+            # prompts/code-review.prompt.md → code-review
+            filename = path_parts[-1]
+            for ext in self.VIRTUAL_FILE_EXTENSIONS:
+                if filename.endswith(ext):
+                    filename = filename[:-len(ext)]
+                    break
+            return f"{repo_name}-{filename}"
+
+    @staticmethod
+    def is_local_path(dep_str: str) -> bool:
+        """Check if a dependency string looks like a local filesystem path.
+        
+        Local paths start with './', '../', '/', or '~'.
+        Protocol-relative URLs ('//...') are explicitly excluded.
+        """
+        s = dep_str.strip()
+        # Reject protocol-relative URLs ('//...')
+        if s.startswith('//'):
+            return False
+        return s.startswith(('./','../', '/', '~/', '~\\', '.\\', '..\\'))
+
+    def get_unique_key(self) -> str:
+        """Get a unique key for this dependency for deduplication.
+        
+        For regular packages: repo_url
+        For virtual packages: repo_url + virtual_path to ensure uniqueness
+        For local packages: the local_path
+        
+        Returns:
+            str: Unique key for this dependency
+        """
+        if self.is_local and self.local_path:
+            return self.local_path
+        if self.is_virtual and self.virtual_path:
+            return f"{self.repo_url}/{self.virtual_path}"
+        return self.repo_url
+    
+    def to_canonical(self) -> str:
+        """Return the canonical form of this dependency for storage in apm.yml.
+        
+        Follows the Docker-style default-registry convention:
+        - Default host (github.com) is stripped  ->  owner/repo
+        - Non-default hosts are preserved         ->  gitlab.com/owner/repo
+        - Virtual paths are appended              ->  owner/repo/path/to/thing
+        - Refs are appended with #                ->  owner/repo#v1.0
+        - Local paths are returned as-is          ->  ./packages/my-pkg
+        
+        No .git suffix, no https://, no git@  -- just the canonical identifier.
+        
+        Returns:
+            str: Canonical dependency string
+        """
+        if self.is_local and self.local_path:
+            return self.local_path
+
+        host = self.host or default_host()
+        is_default = host.lower() == default_host().lower()
+        
+        # Start with optional host prefix
+        if is_default and not self.artifactory_prefix:
+            result = self.repo_url
+        elif self.artifactory_prefix:
+            result = f"{host}/{self.artifactory_prefix}/{self.repo_url}"
+        else:
+            result = f"{host}/{self.repo_url}"
+        
+        # Append virtual path for virtual packages
+        if self.is_virtual and self.virtual_path:
+            result = f"{result}/{self.virtual_path}"
+        
+        # Append reference (branch, tag, commit)
+        if self.reference:
+            result = f"{result}#{self.reference}"
+        
+        return result
+    
+    def get_identity(self) -> str:
+        """Return the identity of this dependency (canonical form without ref/alias).
+        
+        Two deps with the same identity are the same package, regardless of
+        which ref or alias they specify. Used for duplicate detection and uninstall matching.
+        
+        Returns:
+            str: Identity string (e.g., "owner/repo" or "gitlab.com/owner/repo/path")
+        """
+        if self.is_local and self.local_path:
+            return self.local_path
+
+        host = self.host or default_host()
+        is_default = host.lower() == default_host().lower()
+        
+        if is_default and not self.artifactory_prefix:
+            result = self.repo_url
+        elif self.artifactory_prefix:
+            result = f"{host}/{self.artifactory_prefix}/{self.repo_url}"
+        else:
+            result = f"{host}/{self.repo_url}"
+        
+        if self.is_virtual and self.virtual_path:
+            result = f"{result}/{self.virtual_path}"
+        
+        return result
+    
+    @staticmethod
+    def canonicalize(raw: str) -> str:
+        """Parse any raw input form and return its canonical storage form.
+        
+        Convenience method that combines parse() + to_canonical().
+        
+        Args:
+            raw: Any supported input form (shorthand, FQDN, HTTPS, SSH, etc.)
+            
+        Returns:
+            str: Canonical form for apm.yml storage
+        """
+        return DependencyReference.parse(raw).to_canonical()
+    
+    def get_canonical_dependency_string(self) -> str:
+        """Get the host-blind canonical string for filesystem and orphan-detection matching.
+        
+        This returns repo_url (+ virtual_path) without host prefix — it matches
+        the filesystem layout in apm_modules/ which is also host-blind.
+        
+        For identity-based matching that includes non-default hosts, use get_identity().
+        For the full canonical form suitable for apm.yml storage, use to_canonical().
+        
+        Returns:
+            str: Host-blind canonical string (e.g., "owner/repo")
+        """
+        return self.get_unique_key()
+    
+    def get_install_path(self, apm_modules_dir: Path) -> Path:
+        """Get the canonical filesystem path where this package should be installed.
+        
+        This is the single source of truth for where a package lives in apm_modules/.
+        
+        For regular packages:
+            - GitHub: apm_modules/owner/repo/
+            - ADO: apm_modules/org/project/repo/
+        
+        For virtual file/collection packages:
+            - GitHub: apm_modules/owner/<virtual-package-name>/
+            - ADO: apm_modules/org/project/<virtual-package-name>/
+        
+        For subdirectory packages (Claude Skills, nested APM packages):
+            - GitHub: apm_modules/owner/repo/subdir/path/
+            - ADO: apm_modules/org/project/repo/subdir/path/
+
+        For local packages:
+            - apm_modules/_local/<directory-name>/
+        
+        Args:
+            apm_modules_dir: Path to the apm_modules directory
+            
+        Raises:
+            PathTraversalError: If the computed path escapes apm_modules_dir
+        Returns:
+            Path: Absolute path to the package installation directory
+        """
+        if self.is_local and self.local_path:
+            pkg_dir_name = Path(self.local_path).name
+            if pkg_dir_name in ('', '.', '..'):
+                raise PathTraversalError(
+                    f"Invalid local package path '{self.local_path}': "
+                    f"basename must not be empty, '.', or '..'"
+                )
+            result = apm_modules_dir / "_local" / pkg_dir_name
+            ensure_path_within(result, apm_modules_dir)
+            return result
+
+        repo_parts = self.repo_url.split("/")
+
+        # Security: reject traversal in repo_url segments (catches lockfile injection)
+        if any(seg in ('.', '..') for seg in repo_parts):
+            raise PathTraversalError(
+                f"Invalid repo_url '{self.repo_url}': "
+                f"path segments must not be '.' or '..'"
+            )
+
+        # Security: reject traversal in virtual_path (catches lockfile injection)
+        if self.virtual_path and any(
+            seg in ('.', '..') for seg in self.virtual_path.replace('\\', '/').split('/')
+        ):
+            raise PathTraversalError(
+                f"Invalid virtual_path '{self.virtual_path}': "
+                f"path segments must not be '.' or '..'"
+            )
+        result: Path | None = None
+        
+        if self.is_virtual:
+            # Subdirectory packages (like Claude Skills) should use natural path structure
+            if self.is_virtual_subdirectory():
+                # Use repo path + subdirectory path
+                if self.is_azure_devops() and len(repo_parts) >= 3:
+                    # ADO: org/project/repo/subdir
+                    result = apm_modules_dir / repo_parts[0] / repo_parts[1] / repo_parts[2] / self.virtual_path
+                elif len(repo_parts) >= 2:
+                    # owner/repo/subdir or group/subgroup/repo/subdir
+                    result = apm_modules_dir.joinpath(*repo_parts, self.virtual_path)
+            else:
+                # Virtual file/collection: use sanitized package name (flattened)
+                package_name = self.get_virtual_package_name()
+                if self.is_azure_devops() and len(repo_parts) >= 3:
+                    # ADO: org/project/virtual-pkg-name
+                    result = apm_modules_dir / repo_parts[0] / repo_parts[1] / package_name
+                elif len(repo_parts) >= 2:
+                    # owner/virtual-pkg-name (use first segment as namespace)
+                    result = apm_modules_dir / repo_parts[0] / package_name
+        else:
+            # Regular package: use full repo path
+            if self.is_azure_devops() and len(repo_parts) >= 3:
+                # ADO: org/project/repo
+                result = apm_modules_dir / repo_parts[0] / repo_parts[1] / repo_parts[2]
+            elif len(repo_parts) >= 2:
+                # owner/repo or group/subgroup/repo (generic hosts)
+                result = apm_modules_dir.joinpath(*repo_parts)
+
+        if result is None:
+            # Fallback: join all parts
+            result = apm_modules_dir.joinpath(*repo_parts)
+
+        # Security: ensure the computed path stays within apm_modules/
+        ensure_path_within(result, apm_modules_dir)
+        return result
+    
+    @staticmethod
+    def _normalize_ssh_protocol_url(url: str) -> str:
+        """Normalize ssh:// protocol URLs to git@ format for consistent parsing.
+        
+        Converts:
+        - ssh://git@gitlab.com/owner/repo.git → git@gitlab.com:owner/repo.git
+        - ssh://git@host:port/owner/repo.git → git@host:owner/repo.git
+        
+        Non-SSH URLs are returned unchanged.
+        """
+        if not url.startswith('ssh://'):
+            return url
+        
+        # Parse the ssh:// URL
+        # Format: ssh://[user@]host[:port]/path
+        remainder = url[6:]  # Remove 'ssh://'
+        
+        # Extract user if present (typically 'git@')
+        user_prefix = ""
+        if '@' in remainder.split('/')[0]:
+            user_at_idx = remainder.index('@')
+            user_prefix = remainder[:user_at_idx + 1]  # e.g., "git@"
+            remainder = remainder[user_at_idx + 1:]
+        
+        # Extract host (and optional port)
+        slash_idx = remainder.find('/')
+        if slash_idx == -1:
+            return url  # Invalid format, return as-is
+        
+        host_part = remainder[:slash_idx]
+        path_part = remainder[slash_idx + 1:]
+        
+        # Strip port if present (e.g., host:22)
+        if ':' in host_part:
+            host_part = host_part.split(':')[0]
+        
+        # Convert to git@ format: git@host:path
+        if user_prefix:
+            return f"{user_prefix}{host_part}:{path_part}"
+        else:
+            return f"git@{host_part}:{path_part}"
+
+    @classmethod
+    def parse_from_dict(cls, entry: dict) -> "DependencyReference":
+        """Parse an object-style dependency entry from apm.yml.
+        
+        Supports the Cargo-inspired object format:
+        
+            - git: https://gitlab.com/acme/coding-standards.git
+              path: instructions/security
+              ref: v2.0
+        
+            - git: git@bitbucket.org:team/rules.git
+              path: prompts/review.prompt.md
+        
+        Also supports local path entries:
+        
+            - path: ./packages/my-shared-skills
+        
+        Args:
+            entry: Dictionary with 'git' or 'path' (required), plus optional fields
+            
+        Returns:
+            DependencyReference: Parsed dependency reference
+            
+        Raises:
+            ValueError: If the entry is missing required fields or has invalid format
+        """
+        # Support dict-form local path: { path: ./local/dir }
+        if 'path' in entry and 'git' not in entry:
+            local = entry['path']
+            if not isinstance(local, str) or not local.strip():
+                raise ValueError("'path' field must be a non-empty string")
+            local = local.strip()
+            if not cls.is_local_path(local):
+                raise ValueError(
+                    f"Object-style dependency must have a 'git' field, "
+                    f"or 'path' must be a local filesystem path "
+                    f"(starting with './', '../', '/', or '~')"
+                )
+            return cls.parse(local)
+
+        if 'git' not in entry:
+            raise ValueError("Object-style dependency must have a 'git' or 'path' field")
+        
+        git_url = entry['git']
+        if not isinstance(git_url, str) or not git_url.strip():
+            raise ValueError("'git' field must be a non-empty string")
+        
+        sub_path = entry.get('path')
+        ref_override = entry.get('ref')
+        alias_override = entry.get('alias')
+        
+        # Validate sub_path if provided
+        if sub_path is not None:
+            if not isinstance(sub_path, str) or not sub_path.strip():
+                raise ValueError("'path' field must be a non-empty string")
+            sub_path = sub_path.strip().strip('/')
+            # Normalize backslashes to forward slashes for cross-platform safety
+            sub_path = sub_path.replace('\\', '/').strip().strip('/')
+            # Security: reject path traversal
+            if any(seg in ('.', '..') for seg in sub_path.split('/')):
+                raise PathTraversalError(
+                    f"Invalid path '{sub_path}': path segments must not be '.' or '..'"
+                )
+        
+        # Parse the git URL using the standard parser
+        dep = cls.parse(git_url)
+        
+        # Apply overrides from the object fields
+        if ref_override is not None:
+            if not isinstance(ref_override, str) or not ref_override.strip():
+                raise ValueError("'ref' field must be a non-empty string")
+            dep.reference = ref_override.strip()
+        
+        if alias_override is not None:
+            if not isinstance(alias_override, str) or not alias_override.strip():
+                raise ValueError("'alias' field must be a non-empty string")
+            alias_override = alias_override.strip()
+            if not re.match(r'^[a-zA-Z0-9._-]+$', alias_override):
+                raise ValueError(f"Invalid alias: {alias_override}. Aliases can only contain letters, numbers, dots, underscores, and hyphens")
+            dep.alias = alias_override
+        
+        # Apply sub-path as virtual package
+        if sub_path:
+            dep.virtual_path = sub_path
+            dep.is_virtual = True
+        
+        return dep
+
+    @classmethod
+    def _detect_virtual_package(cls, dependency_str: str):
+        """Detect whether *dependency_str* refers to a virtual package.
+
+        Returns:
+            (is_virtual_package, virtual_path, validated_host)
+        """
+        # Temporarily remove reference for path segment counting
+        temp_str = dependency_str
+        if '#' in temp_str:
+            temp_str = temp_str.rsplit('#', 1)[0]
+
+        is_virtual_package = False
+        virtual_path = None
+        validated_host = None
+
+        if temp_str.startswith(('git@', 'https://', 'http://')):
+            return is_virtual_package, virtual_path, validated_host
+
+        check_str = temp_str
+
+        if '/' in check_str:
+            first_segment = check_str.split('/')[0]
+
+            if '.' in first_segment:
+                test_url = f"https://{check_str}"
+                try:
+                    parsed = urllib.parse.urlparse(test_url)
+                    hostname = parsed.hostname
+
+                    if hostname and is_supported_git_host(hostname):
+                        validated_host = hostname
+                        path_parts = parsed.path.lstrip('/').split('/')
+                        if len(path_parts) >= 2:
+                            check_str = '/'.join(check_str.split('/')[1:])
+                    else:
+                        raise ValueError(
+                            unsupported_host_error(hostname or first_segment)
+                        )
+                except (ValueError, AttributeError) as e:
+                    if isinstance(e, ValueError) and "Invalid Git host" in str(e):
+                        raise
+                    raise ValueError(
+                        unsupported_host_error(first_segment)
+                    )
+            elif check_str.startswith('gh/'):
+                check_str = '/'.join(check_str.split('/')[1:])
+
+        path_segments = [seg for seg in check_str.split('/') if seg]
+
+        is_ado = validated_host is not None and is_azure_devops_hostname(validated_host)
+        is_generic_host = (validated_host is not None
+                           and not is_github_hostname(validated_host)
+                           and not is_azure_devops_hostname(validated_host))
+
+        if is_ado and '_git' in path_segments:
+            git_idx = path_segments.index('_git')
+            path_segments = path_segments[:git_idx] + path_segments[git_idx+1:]
+
+        # Detect Artifactory VCS paths (artifactory/{repo-key}/{owner}/{repo})
+        is_artifactory = is_generic_host and is_artifactory_path(path_segments)
+
+        if is_ado:
+            min_base_segments = 3
+        elif is_artifactory:
+            # Artifactory: artifactory/{repo-key}/{owner}/{repo}
+            min_base_segments = 4
+        elif is_generic_host:
+            has_virtual_ext = any(
+                any(seg.endswith(ext) for ext in cls.VIRTUAL_FILE_EXTENSIONS)
+                for seg in path_segments
+            )
+            has_collection = 'collections' in path_segments
+            if has_virtual_ext or has_collection:
+                min_base_segments = 2
+            else:
+                min_base_segments = len(path_segments)
+        else:
+            min_base_segments = 2
+
+        min_virtual_segments = min_base_segments + 1
+
+        if len(path_segments) >= min_virtual_segments:
+            is_virtual_package = True
+            virtual_path = '/'.join(path_segments[min_base_segments:])
+
+            # Security: reject path traversal in virtual path
+            vp_check = virtual_path.replace('\\', '/')
+            if any(seg in ('.', '..') for seg in vp_check.split('/')):
+                raise PathTraversalError(
+                    f"Invalid virtual path '{virtual_path}': path segments must not be '.' or '..'"
+                )
+
+            if '/collections/' in check_str or virtual_path.startswith('collections/'):
+                pass
+            elif any(virtual_path.endswith(ext) for ext in cls.VIRTUAL_FILE_EXTENSIONS):
+                pass
+            else:
+                last_segment = virtual_path.split('/')[-1]
+                if '.' in last_segment:
+                    raise InvalidVirtualPackageExtensionError(
+                        f"Invalid virtual package path '{virtual_path}'. "
+                        f"Individual files must end with one of: {', '.join(cls.VIRTUAL_FILE_EXTENSIONS)}. "
+                        f"For subdirectory packages, the path should not have a file extension."
+                    )
+
+        return is_virtual_package, virtual_path, validated_host
+
+    @staticmethod
+    def _parse_ssh_url(dependency_str: str):
+        """Parse an SSH-style git URL (``git@host:owner/repo``).
+
+        Returns:
+            ``(host, repo_url, reference, alias)`` or *None* if not an SSH URL.
+        """
+        ssh_match = re.match(r'^git@([^:]+):(.+)$', dependency_str)
+        if not ssh_match:
+            return None
+
+        host = ssh_match.group(1)
+        ssh_repo_part = ssh_match.group(2)
+
+        reference = None
+        alias = None
+
+        if "@" in ssh_repo_part:
+            ssh_repo_part, alias = ssh_repo_part.rsplit("@", 1)
+            alias = alias.strip()
+
+        if "#" in ssh_repo_part:
+            repo_part, reference = ssh_repo_part.rsplit("#", 1)
+            reference = reference.strip()
+        else:
+            repo_part = ssh_repo_part
+
+        if repo_part.endswith('.git'):
+            repo_part = repo_part[:-4]
+
+        repo_url = repo_part.strip()
+        return host, repo_url, reference, alias
+
+    @classmethod
+    def _parse_standard_url(cls, dependency_str: str, is_virtual_package: bool,
+                            virtual_path, validated_host):
+        """Parse a non-SSH dependency string (HTTPS, FQDN, or shorthand).
+
+        Returns:
+            ``(host, repo_url, reference, alias)``
+        """
+        host = None
+
+        alias = None
+
+        reference = None
+        if "#" in dependency_str:
+            repo_part, reference = dependency_str.rsplit("#", 1)
+            reference = reference.strip()
+        else:
+            repo_part = dependency_str
+
+        repo_url = repo_part.strip()
+
+        # For virtual packages, extract just the owner/repo part (or org/project/repo for ADO)
+        if is_virtual_package and not repo_url.startswith(("https://", "http://")):
+            parts = repo_url.split("/")
+
+            if '_git' in parts:
+                git_idx = parts.index('_git')
+                parts = parts[:git_idx] + parts[git_idx+1:]
+
+            if len(parts) >= 3 and is_supported_git_host(parts[0]):
+                host = parts[0]
+                if is_azure_devops_hostname(parts[0]):
+                    if len(parts) < 5:
+                        raise ValueError("Invalid Azure DevOps virtual package format: must be dev.azure.com/org/project/repo/path")
+                    repo_url = "/".join(parts[1:4])
+                elif is_artifactory_path(parts[1:]):
+                    art_result = parse_artifactory_path(parts[1:])
+                    if art_result:
+                        repo_url = f"{art_result[1]}/{art_result[2]}"
+                else:
+                    repo_url = "/".join(parts[1:3])
+            elif len(parts) >= 2:
+                if not host:
+                    host = default_host()
+                if validated_host and is_azure_devops_hostname(validated_host):
+                    if len(parts) < 4:
+                        raise ValueError("Invalid Azure DevOps virtual package format: expected at least org/project/repo/path")
+                    repo_url = "/".join(parts[:3])
+                else:
+                    repo_url = "/".join(parts[:2])
+
+        # Normalize to URL format for secure parsing
+        if repo_url.startswith(("https://", "http://")):
+            parsed_url = urllib.parse.urlparse(repo_url)
+            host = parsed_url.hostname or ""
+        else:
+            parts = repo_url.split("/")
+
+            if '_git' in parts:
+                git_idx = parts.index('_git')
+                parts = parts[:git_idx] + parts[git_idx+1:]
+
+            if len(parts) >= 3 and is_supported_git_host(parts[0]):
+                host = parts[0]
+                if is_azure_devops_hostname(host) and len(parts) >= 4:
+                    user_repo = "/".join(parts[1:4])
+                elif not is_github_hostname(host) and not is_azure_devops_hostname(host):
+                    if is_artifactory_path(parts[1:]):
+                        art_result = parse_artifactory_path(parts[1:])
+                        if art_result:
+                            user_repo = f"{art_result[1]}/{art_result[2]}"
+                        else:
+                            user_repo = "/".join(parts[1:])
+                    else:
+                        user_repo = "/".join(parts[1:])
+                else:
+                    user_repo = "/".join(parts[1:3])
+            elif len(parts) >= 2 and "." not in parts[0]:
+                if not host:
+                    host = default_host()
+                if is_azure_devops_hostname(host) and len(parts) >= 3:
+                    user_repo = "/".join(parts[:3])
+                elif host and not is_github_hostname(host) and not is_azure_devops_hostname(host):
+                    user_repo = "/".join(parts)
+                else:
+                    user_repo = "/".join(parts[:2])
+            else:
+                raise ValueError(f"Use 'user/repo' or 'github.com/user/repo' or 'dev.azure.com/org/project/repo' format")
+
+            if not user_repo or "/" not in user_repo:
+                raise ValueError(f"Invalid repository format: {repo_url}. Expected 'user/repo' or 'org/project/repo'")
+
+            uparts = user_repo.split("/")
+            is_ado_host = host and is_azure_devops_hostname(host)
+
+            if is_ado_host:
+                if len(uparts) < 3:
+                    raise ValueError(f"Invalid Azure DevOps repository format: {repo_url}. Expected 'org/project/repo'")
+            else:
+                if len(uparts) < 2:
+                    raise ValueError(f"Invalid repository format: {repo_url}. Expected 'user/repo'")
+
+            allowed_pattern = r'^[a-zA-Z0-9._\- ]+$' if is_ado_host else r'^[a-zA-Z0-9._-]+$'
+            for part in uparts:
+                if part in ('.', '..'):
+                    raise PathTraversalError(
+                        f"Invalid repository path component: '{part}' is a traversal sequence"
+                    )
+                if not re.match(allowed_pattern, part.rstrip('.git')):
+                    raise ValueError(f"Invalid repository path component: {part}")
+
+            quoted_repo = '/'.join(urllib.parse.quote(p, safe='') for p in uparts)
+            github_url = urllib.parse.urljoin(f"https://{host}/", quoted_repo)
+            parsed_url = urllib.parse.urlparse(github_url)
+
+        hostname = parsed_url.hostname or ""
+        if not is_supported_git_host(hostname):
+            raise ValueError(unsupported_host_error(hostname or parsed_url.netloc))
+
+        path = parsed_url.path.strip("/")
+        if not path:
+            raise ValueError("Repository path cannot be empty")
+
+        if path.endswith(".git"):
+            path = path[:-4]
+
+        path_parts = [urllib.parse.unquote(p) for p in path.split("/")]
+        if '_git' in path_parts:
+            git_idx = path_parts.index('_git')
+            path_parts = path_parts[:git_idx] + path_parts[git_idx+1:]
+
+        is_ado_host = is_azure_devops_hostname(hostname)
+
+        if is_ado_host:
+            if len(path_parts) != 3:
+                raise ValueError(f"Invalid Azure DevOps repository path: expected 'org/project/repo', got '{path}'")
+        else:
+            if len(path_parts) < 2:
+                raise ValueError(f"Invalid repository path: expected at least 'user/repo', got '{path}'")
+            for pp in path_parts:
+                if any(pp.endswith(ext) for ext in cls.VIRTUAL_FILE_EXTENSIONS):
+                    raise ValueError(
+                        f"Invalid repository path: '{path}' contains a virtual file extension. "
+                        f"Use the dict format with 'path:' for virtual packages in HTTPS URLs"
+                    )
+
+        allowed_pattern = r'^[a-zA-Z0-9._\- ]+$' if is_ado_host else r'^[a-zA-Z0-9._-]+$'
+        for i, part in enumerate(path_parts):
+            if not part:
+                raise ValueError(f"Invalid repository format: path component {i+1} cannot be empty")
+            if not re.match(allowed_pattern, part):
+                raise ValueError(f"Invalid repository path component: {part}")
+
+        repo_url = "/".join(path_parts)
+
+        if not host:
+            host = default_host()
+
+        return host, repo_url, reference, alias
+
+    @classmethod
+    def parse(cls, dependency_str: str) -> "DependencyReference":
+        """Parse a dependency string into a DependencyReference.
+        
+        Supports formats:
+        - user/repo
+        - user/repo#branch
+        - user/repo#v1.0.0
+        - user/repo#commit_sha
+        - github.com/user/repo#ref
+        - user/repo@alias
+        - user/repo#ref@alias
+        - user/repo/path/to/file.prompt.md (virtual file package)
+        - user/repo/collections/name (virtual collection package)
+        - https://gitlab.com/owner/repo.git (generic HTTPS git URL)
+        - git@gitlab.com:owner/repo.git (SSH git URL)
+        - ssh://git@gitlab.com/owner/repo.git (SSH protocol URL)
+        
+        - ./local/path (local filesystem path)
+        - /absolute/path (local filesystem path)
+        - ../relative/path (local filesystem path)
+        
+        Any valid FQDN is accepted as a git host (GitHub, GitLab, Bitbucket,
+        self-hosted instances, etc.).
+        
+        Args:
+            dependency_str: The dependency string to parse
+            
+        Returns:
+            DependencyReference: Parsed dependency reference
+            
+        Raises:
+            ValueError: If the dependency string format is invalid
+        """
+        if not dependency_str.strip():
+            raise ValueError("Empty dependency string")
+
+        dependency_str = urllib.parse.unquote(dependency_str)
+
+        if any(ord(c) < 32 for c in dependency_str):
+            raise ValueError("Dependency string contains invalid control characters")
+
+        # --- Local path detection (must run before URL/host parsing) ---
+        if cls.is_local_path(dependency_str):
+            local = dependency_str.strip()
+            pkg_name = Path(local).name
+            if not pkg_name or pkg_name in ('.', '..'):
+                raise ValueError(
+                    f"Local path '{local}' does not resolve to a named directory. "
+                    f"Use a path that ends with a directory name "
+                    f"(e.g., './my-package' instead of './')."
+                )
+            return cls(
+                repo_url=f"_local/{pkg_name}",
+                is_local=True,
+                local_path=local,
+            )
+        
+        if dependency_str.startswith('//'):
+            raise ValueError(unsupported_host_error("//...", context="Protocol-relative URLs are not supported"))
+        
+        dependency_str = cls._normalize_ssh_protocol_url(dependency_str)
+
+        # Phase 1: detect virtual packages
+        is_virtual_package, virtual_path, validated_host = cls._detect_virtual_package(dependency_str)
+
+        # Phase 2: parse SSH or standard URL
+        ssh_result = cls._parse_ssh_url(dependency_str)
+        if ssh_result:
+            host, repo_url, reference, alias = ssh_result
+        else:
+            host, repo_url, reference, alias = cls._parse_standard_url(
+                dependency_str, is_virtual_package, virtual_path, validated_host
+            )
+
+        # Phase 3: final validation and ADO field extraction
+        is_ado_final = host and is_azure_devops_hostname(host)
+        if is_ado_final:
+            if not re.match(r'^[a-zA-Z0-9._-]+/[a-zA-Z0-9._\- ]+/[a-zA-Z0-9._-]+$', repo_url):
+                raise ValueError(f"Invalid Azure DevOps repository format: {repo_url}. Expected 'org/project/repo'")
+            ado_parts = repo_url.split('/')
+            ado_organization = ado_parts[0]
+            ado_project = ado_parts[1]
+            ado_repo = ado_parts[2]
+        else:
+            segments = repo_url.split('/')
+            if len(segments) < 2:
+                raise ValueError(f"Invalid repository format: {repo_url}. Expected 'user/repo'")
+            if not all(re.match(r'^[a-zA-Z0-9._-]+$', s) for s in segments):
+                raise ValueError(f"Invalid repository format: {repo_url}. Contains invalid characters")
+            for seg in segments:
+                if any(seg.endswith(ext) for ext in cls.VIRTUAL_FILE_EXTENSIONS):
+                    raise ValueError(
+                        f"Invalid repository format: '{repo_url}' contains a virtual file extension. "
+                        f"Use the dict format with 'path:' for virtual packages in SSH/HTTPS URLs"
+                    )
+            ado_organization = None
+            ado_project = None
+            ado_repo = None
+        
+        if alias and not re.match(r'^[a-zA-Z0-9._-]+$', alias):
+            raise ValueError(f"Invalid alias: {alias}. Aliases can only contain letters, numbers, dots, underscores, and hyphens")
+
+        # Extract Artifactory prefix from the original path if applicable
+        artifactory_prefix = None
+        if host and not is_ado_final:
+            _art_str = dependency_str.split('#')[0].split('@')[0]
+            # Strip scheme if present (e.g., https://host/artifactory/...)
+            if '://' in _art_str:
+                _art_str = _art_str.split('://', 1)[1]
+            _art_segs = _art_str.replace(f"{host}/", "", 1).split("/")
+            if is_artifactory_path(_art_segs):
+                art_result = parse_artifactory_path(_art_segs)
+                if art_result:
+                    artifactory_prefix = art_result[0]
+
+        return cls(
+            repo_url=repo_url,
+            host=host,
+            reference=reference,
+            alias=alias,
+            virtual_path=virtual_path,
+            is_virtual=is_virtual_package,
+            ado_organization=ado_organization,
+            ado_project=ado_project,
+            ado_repo=ado_repo,
+            artifactory_prefix=artifactory_prefix
+        )
+
+    def to_github_url(self) -> str:
+        """Convert to full repository URL.
+        
+        For Azure DevOps, generates: https://dev.azure.com/org/project/_git/repo
+        For GitHub, generates: https://github.com/owner/repo
+        For local packages, returns the local path.
+        """
+        if self.is_local and self.local_path:
+            return self.local_path
+
+        host = self.host or default_host()
+        
+        if self.is_azure_devops():
+            # ADO format: https://dev.azure.com/org/project/_git/repo
+            project = urllib.parse.quote(self.ado_project, safe='')
+            return f"https://{host}/{self.ado_organization}/{project}/_git/{self.ado_repo}"
+        elif self.artifactory_prefix:
+            return f"https://{host}/{self.artifactory_prefix}/{self.repo_url}"
+        else:
+            # GitHub format: https://github.com/owner/repo
+            return f"https://{host}/{self.repo_url}"
+    
+    def to_clone_url(self) -> str:
+        """Convert to a clone-friendly URL (same as to_github_url for most purposes)."""
+        return self.to_github_url()
+
+    def get_display_name(self) -> str:
+        """Get display name for this dependency (alias or repo name)."""
+        if self.alias:
+            return self.alias
+        if self.is_local and self.local_path:
+            return self.local_path
+        if self.is_virtual:
+            return self.get_virtual_package_name()
+        return self.repo_url  # Full repo URL for disambiguation
+
+    def __str__(self) -> str:
+        """String representation of the dependency reference."""
+        if self.is_local and self.local_path:
+            return self.local_path
+        if self.host:
+            if self.artifactory_prefix:
+                result = f"{self.host}/{self.artifactory_prefix}/{self.repo_url}"
+            else:
+                result = f"{self.host}/{self.repo_url}"
+        else:
+            result = self.repo_url
+        if self.virtual_path:
+            result += f"/{self.virtual_path}"
+        if self.reference:
+            result += f"#{self.reference}"
+        if self.alias:
+            result += f"@{self.alias}"
+        return result
diff --git a/src/apm_cli/models/dependency/types.py b/src/apm_cli/models/dependency/types.py
new file mode 100644
index 000000000..7344cc925
--- /dev/null
+++ b/src/apm_cli/models/dependency/types.py
@@ -0,0 +1,63 @@
+"""Dependency type definitions  -- enums and simple dataclasses."""
+
+import re
+from dataclasses import dataclass
+from enum import Enum
+from typing import Optional
+
+
+class GitReferenceType(Enum):
+    """Types of Git references supported."""
+    BRANCH = "branch"
+    TAG = "tag"
+    COMMIT = "commit"
+
+
+class VirtualPackageType(Enum):
+    """Type of virtual package."""
+    FILE = "file"                 # Individual file (*.prompt.md, etc.)
+    COLLECTION = "collection"     # Collection virtual package
+    SUBDIRECTORY = "subdirectory"  # Subdirectory package
+
+
+@dataclass
+class ResolvedReference:
+    """Represents a resolved Git reference."""
+    original_ref: str
+    ref_type: GitReferenceType
+    resolved_commit: Optional[str] = None
+    ref_name: str = ""  # The actual branch/tag/commit name
+    
+    def __str__(self) -> str:
+        """String representation of resolved reference."""
+        if not self.resolved_commit:
+            return self.ref_name
+        if self.ref_type == GitReferenceType.COMMIT:
+            return f"{self.resolved_commit[:8]}"
+        return f"{self.ref_name} ({self.resolved_commit[:8]})"
+
+
+def parse_git_reference(ref_string: str) -> tuple[GitReferenceType, str]:
+    """Parse a git reference string to determine its type.
+    
+    Args:
+        ref_string: Git reference (branch, tag, or commit)
+        
+    Returns:
+        tuple: (GitReferenceType, cleaned_reference)
+    """
+    if not ref_string:
+        return GitReferenceType.BRANCH, "main"  # Default to main branch
+    
+    ref = ref_string.strip()
+    
+    # Check if it looks like a commit SHA (40 hex chars or 7+ hex chars)
+    if re.match(r'^[a-f0-9]{7,40}$', ref.lower()):
+        return GitReferenceType.COMMIT, ref
+    
+    # Check if it looks like a semantic version tag
+    if re.match(r'^v?\d+\.\d+\.\d+', ref):
+        return GitReferenceType.TAG, ref
+    
+    # Otherwise assume it's a branch
+    return GitReferenceType.BRANCH, ref
diff --git a/src/apm_cli/models/results.py b/src/apm_cli/models/results.py
new file mode 100644
index 000000000..5619ed2bf
--- /dev/null
+++ b/src/apm_cli/models/results.py
@@ -0,0 +1,25 @@
+"""Typed result containers for APM operations."""
+
+from dataclasses import dataclass
+
+
+@dataclass
+class InstallResult:
+    """Result of an APM install operation."""
+
+    installed_count: int = 0
+    prompts_integrated: int = 0
+    agents_integrated: int = 0
+    diagnostics: object = None  # DiagnosticCollector or None
+
+
+@dataclass
+class PrimitiveCounts:
+    """Counts of primitives in a package."""
+
+    prompts: int = 0
+    agents: int = 0
+    instructions: int = 0
+    skills: int = 0
+    hooks: int = 0
+    commands: int = 0
diff --git a/src/apm_cli/models/validation.py b/src/apm_cli/models/validation.py
index d552f687e..419f85c87 100644
--- a/src/apm_cli/models/validation.py
+++ b/src/apm_cli/models/validation.py
@@ -8,6 +8,8 @@
 from pathlib import Path
 from typing import TYPE_CHECKING, List, Optional
 
+from ..constants import APM_DIR, APM_YML_FILENAME, SKILL_MD_FILENAME
+
 if TYPE_CHECKING:
     from .apm_package import APMPackage
 
@@ -127,7 +129,7 @@ def summary(self) -> str:
 
 def _has_hook_json(package_path: Path) -> bool:
     """Check if the package has hook JSON files in hooks/ or .apm/hooks/."""
-    for hooks_dir in [package_path / "hooks", package_path / ".apm" / "hooks"]:
+    for hooks_dir in [package_path / "hooks", package_path / APM_DIR / "hooks"]:
         if hooks_dir.exists() and any(hooks_dir.glob("*.json")):
             return True
     return False
@@ -161,8 +163,8 @@ def validate_apm_package(package_path: Path) -> ValidationResult:
         return result
     
     # Detect package type
-    apm_yml_path = package_path / "apm.yml"
-    skill_md_path = package_path / "SKILL.md"
+    apm_yml_path = package_path / APM_YML_FILENAME
+    skill_md_path = package_path / SKILL_MD_FILENAME
 
     # Check for plugin.json  -- optional metadata, not a detection gate
     from ..utils.helpers import find_plugin_json
@@ -280,7 +282,7 @@ def _validate_claude_skill(package_path: Path, skill_md_path: Path, result: Vali
         result.package = package
         
     except Exception as e:
-        result.add_error(f"Failed to process SKILL.md: {e}")
+        result.add_error(f"Failed to process {SKILL_MD_FILENAME}: {e}")
         return result
     
     return result
@@ -342,13 +344,13 @@ def _validate_apm_package_with_yml(package_path: Path, apm_yml_path: Path, resul
         return result
     
     # Check for .apm directory
-    apm_dir = package_path / ".apm"
+    apm_dir = package_path / APM_DIR
     if not apm_dir.exists():
-        result.add_error("Missing required directory: .apm/")
+        result.add_error(f"Missing required directory: {APM_DIR}/")
         return result
     
     if not apm_dir.is_dir():
-        result.add_error(".apm must be a directory")
+        result.add_error(f"{APM_DIR} must be a directory")
         return result
     
     # Check if .apm directory has any content
@@ -376,7 +378,7 @@ def _validate_apm_package_with_yml(package_path: Path, apm_yml_path: Path, resul
         has_primitives = _has_hook_json(package_path)
     
     if not has_primitives:
-        result.add_warning("No primitive files found in .apm/ directory")
+        result.add_warning(f"No primitive files found in {APM_DIR}/ directory")
     
     # Version format validation (basic semver check)
     if package and package.version is not None:
diff --git a/tests/unit/compilation/test_compile_target_flag.py b/tests/unit/compilation/test_compile_target_flag.py
index 7178d1246..d3aeffd1c 100644
--- a/tests/unit/compilation/test_compile_target_flag.py
+++ b/tests/unit/compilation/test_compile_target_flag.py
@@ -442,6 +442,17 @@ def test_target_flag_accepts_claude(self, runner, temp_project):
         finally:
             os.chdir(original_dir)
 
+    def test_target_flag_accepts_opencode(self, runner, temp_project):
+        """Test that --target opencode is accepted."""
+        original_dir = os.getcwd()
+        try:
+            os.chdir(temp_project)
+            result = runner.invoke(cli, ["compile", "--target", "opencode", "--dry-run"])
+
+            assert "Invalid value for '--target'" not in result.output
+        finally:
+            os.chdir(original_dir)
+
     def test_target_flag_accepts_all(self, runner, temp_project):
         """Test that --target all is accepted."""
         original_dir = os.getcwd()
@@ -453,6 +464,16 @@ def test_target_flag_accepts_all(self, runner, temp_project):
         finally:
             os.chdir(original_dir)
 
+    def test_target_flag_help_lists_opencode(self, runner):
+        """Test that compile --help documents the opencode target."""
+        result = runner.invoke(cli, ["compile", "--help"])
+
+        assert result.exit_code == 0
+        assert "[vscode|agents|claude|opencode|all]" in result.output
+        assert "Target platform: vscode/agents (AGENTS.md)," in result.output
+        assert "claude (CLAUDE.md), opencode (AGENTS.md), or" in result.output
+        assert "all. Auto-detects if not specified." in result.output
+
     def test_target_flag_rejects_invalid(self, runner, temp_project):
         """Test that invalid target value is rejected."""
         original_dir = os.getcwd()
diff --git a/tests/unit/test_install_command.py b/tests/unit/test_install_command.py
index efd6543e8..c210733fe 100644
--- a/tests/unit/test_install_command.py
+++ b/tests/unit/test_install_command.py
@@ -9,6 +9,8 @@
 from click.testing import CliRunner
 from unittest.mock import patch, MagicMock
 
+from apm_cli.models.results import InstallResult
+
 from apm_cli.cli import cli
 
 
@@ -78,11 +80,9 @@ def test_install_no_apm_yml_with_packages_creates_minimal_apm_yml(
             mock_apm_package.from_apm_yml.return_value = mock_pkg_instance
 
             # Mock the install function to avoid actual installation
-            mock_install_apm.return_value = (0, 0, 0, MagicMock(has_diagnostics=False, has_critical_security=False))
+            mock_install_apm.return_value = InstallResult(diagnostics=MagicMock(has_diagnostics=False, has_critical_security=False))
 
             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()
@@ -116,7 +116,7 @@ def test_install_no_apm_yml_with_multiple_packages(
             mock_pkg_instance.get_mcp_dependencies.return_value = []
             mock_apm_package.from_apm_yml.return_value = mock_pkg_instance
 
-            mock_install_apm.return_value = (0, 0, 0, MagicMock(has_diagnostics=False, has_critical_security=False))
+            mock_install_apm.return_value = InstallResult(diagnostics=MagicMock(has_diagnostics=False, has_critical_security=False))
 
             result = self.runner.invoke(cli, ["install", "org1/pkg1", "org2/pkg2"])
 
@@ -156,7 +156,7 @@ def test_install_existing_apm_yml_preserves_behavior(
             mock_pkg_instance.get_mcp_dependencies.return_value = []
             mock_apm_package.from_apm_yml.return_value = mock_pkg_instance
 
-            mock_install_apm.return_value = (0, 0, 0, MagicMock(has_diagnostics=False, has_critical_security=False))
+            mock_install_apm.return_value = InstallResult(diagnostics=MagicMock(has_diagnostics=False, has_critical_security=False))
 
             result = self.runner.invoke(cli, ["install"])
 
@@ -194,7 +194,7 @@ def test_install_auto_created_apm_yml_has_correct_metadata(
             mock_pkg_instance.get_mcp_dependencies.return_value = []
             mock_apm_package.from_apm_yml.return_value = mock_pkg_instance
 
-            mock_install_apm.return_value = (0, 0, 0, MagicMock(has_diagnostics=False, has_critical_security=False))
+            mock_install_apm.return_value = InstallResult(diagnostics=MagicMock(has_diagnostics=False, has_critical_security=False))
 
             result = self.runner.invoke(cli, ["install", "test/package"])
 
@@ -220,6 +220,15 @@ def test_install_invalid_package_format_with_no_apm_yml(self, mock_validate):
             assert Path("apm.yml").exists()
             assert "Invalid package format" in result.output
 
+    def test_install_help_verbose_option_describes_diagnostics(self):
+        """Test that install --help explains the verbose diagnostic output."""
+        result = self.runner.invoke(cli, ["install", "--help"])
+
+        assert result.exit_code == 0
+        assert "--verbose" in result.output
+        assert "Show individual file paths and full error" in result.output
+        assert "details in the diagnostic summary" in result.output
+
     @patch("apm_cli.commands.install._validate_package_exists")
     @patch("apm_cli.commands.install.APM_DEPS_AVAILABLE", True)
     @patch("apm_cli.commands.install.APMPackage")
