From 8019018c7f102cd3c188261be9688e0924e10cd4 Mon Sep 17 00:00:00 2001
From: SebastienDegodez <sebastien.degodez@gmail.com>
Date: Tue, 10 Feb 2026 00:09:11 +0100
Subject: [PATCH 1/8] feat(plugin): implement plugin management system and CLI
 commands

Signed-off-by: SebastienDegodez <sebastien.degodez@gmail.com>
---
 docs/index.md                                 |   1 +
 docs/plugins.md                               | 281 ++++++++++++++
 src/apm_cli/cli.py                            |   8 +-
 src/apm_cli/deps/github_downloader.py         |  19 +-
 src/apm_cli/deps/plugin_parser.py             | 178 +++++++++
 src/apm_cli/models/apm_package.py             |  62 +++-
 src/apm_cli/models/plugin.py                  | 202 ++++++++++
 src/apm_cli/primitives/discovery.py           |  86 +++++
 src/apm_cli/utils/console.py                  |   5 +-
 src/apm_cli/utils/helpers.py                  |  23 ++
 .../.claude-plugin/plugin.json                |  11 +
 .../agents/test-agent.agent.md                |   8 +
 .../commands/test-command.md                  |   7 +
 .../skills/test-skill/SKILL.md                |   8 +
 .../.github/plugin/plugin.json                |  11 +
 .../agents/test-agent.agent.md                |   8 +
 .../commands/test-command.md                  |   7 +
 .../skills/test-skill/SKILL.md                |   8 +
 .../mock-plugin/.apm/agents/example.agent.md  |  18 +
 .../mock-plugin/.apm/agents/helper.agent.md   |  14 +
 .../fixtures/mock-plugin/.apm/hooks/setup.sh  |  11 +
 .../coding-standards.instructions.md          |  29 ++
 .../.apm/prompts/example.prompt.md            |  18 +
 .../mock-plugin/.apm/prompts/setup.prompt.md  |  18 +
 .../.apm/skills/example-skill/SKILL.md        |  20 +
 tests/fixtures/mock-plugin/apm.yml            |   6 +
 .../test_marketplace_plugin_integration.py    | 347 ++++++++++++++++++
 27 files changed, 1402 insertions(+), 12 deletions(-)
 create mode 100644 docs/plugins.md
 create mode 100644 src/apm_cli/deps/plugin_parser.py
 create mode 100644 src/apm_cli/models/plugin.py
 create mode 100644 tests/fixtures/mock-claude-plugin/.claude-plugin/plugin.json
 create mode 100644 tests/fixtures/mock-claude-plugin/agents/test-agent.agent.md
 create mode 100644 tests/fixtures/mock-claude-plugin/commands/test-command.md
 create mode 100644 tests/fixtures/mock-claude-plugin/skills/test-skill/SKILL.md
 create mode 100644 tests/fixtures/mock-marketplace-plugin/.github/plugin/plugin.json
 create mode 100644 tests/fixtures/mock-marketplace-plugin/agents/test-agent.agent.md
 create mode 100644 tests/fixtures/mock-marketplace-plugin/commands/test-command.md
 create mode 100644 tests/fixtures/mock-marketplace-plugin/skills/test-skill/SKILL.md
 create mode 100644 tests/fixtures/mock-plugin/.apm/agents/example.agent.md
 create mode 100644 tests/fixtures/mock-plugin/.apm/agents/helper.agent.md
 create mode 100644 tests/fixtures/mock-plugin/.apm/hooks/setup.sh
 create mode 100644 tests/fixtures/mock-plugin/.apm/instructions/coding-standards.instructions.md
 create mode 100644 tests/fixtures/mock-plugin/.apm/prompts/example.prompt.md
 create mode 100644 tests/fixtures/mock-plugin/.apm/prompts/setup.prompt.md
 create mode 100644 tests/fixtures/mock-plugin/.apm/skills/example-skill/SKILL.md
 create mode 100644 tests/fixtures/mock-plugin/apm.yml
 create mode 100644 tests/integration/test_marketplace_plugin_integration.py

diff --git a/docs/index.md b/docs/index.md
index 2638e0474..126a8590e 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -18,6 +18,7 @@ New to APM? Follow this learning path for the fastest way to get productive:
 
 ### Command Line Interface
 - **[CLI Reference](cli-reference.md)** - Complete command documentation with examples
+- **[Plugin System](plugins.md)** - Installing and using plugins with plugin.json format
 - **[Integration Guide](integrations.md)** - VSCode, Spec-kit, AI runtimes, and tool compatibility
 
 ### Framework Implementation  
diff --git a/docs/plugins.md b/docs/plugins.md
new file mode 100644
index 000000000..fc7778556
--- /dev/null
+++ b/docs/plugins.md
@@ -0,0 +1,281 @@
+# Plugins
+
+APM supports plugins through the `plugin.json` format. Plugins are automatically detected and integrated into your project as standard APM dependencies.
+
+## Overview
+
+Plugins are packages that contain:
+
+- **Skills** - Reusable agent personas and expertise
+- **Agents** - AI agent definitions
+- **Commands** - Executable prompts and workflows  
+- **Instructions** - Context and guidelines
+
+APM automatically detects plugins with `plugin.json` manifests and synthesizes `apm.yml` from the metadata, treating them identically to other APM packages.
+
+## Installation
+
+Install plugins using the standard `apm install` command:
+
+```bash
+# Install a plugin from GitHub
+apm install owner/repo/plugin-name
+
+# Or add to apm.yml
+dependencies:
+  apm:
+    - anthropics/claude-code-plugins/commit-commands#v1.2.0
+```
+
+## How APM Handles Plugins
+
+When you run `apm install owner/repo/plugin-name`:
+
+1. **Clone** - APM clones the repository to `apm_modules/`
+2. **Detect** - It searches for `plugin.json` in priority order:
+   - `.github/plugin/plugin.json` (GitHub Copilot format)
+   - `.claude-plugin/plugin.json` (Claude format)
+   - `plugin.json` (root)
+3. **Map Artifacts** - Plugin primitives from the repository root are mapped into `.apm/`:
+   - `agents/` → `.apm/agents/`
+   - `skills/` → `.apm/skills/`
+   - `commands/` → `.apm/prompts/`
+4. **Synthesize** - `apm.yml` is automatically generated from plugin metadata
+5. **Integrate** - The plugin is now a standard dependency with:
+   - Version pinning via `apm.lock`
+   - Transitive dependency resolution
+   - Conflict detection
+   - Everything else APM packages support
+
+This unified approach means **no special commands needed** — plugins work exactly like any other APM package.
+
+## Plugin Format
+
+A plugin repository contains a `plugin.json` manifest and primitives at the repository root.
+
+### Supported Plugin Structures
+
+APM supports multiple plugin manifest locations to accommodate different platforms:
+
+#### GitHub Copilot Format
+```
+plugin-repo/
+├── .github/
+│   └── plugin/
+│       └── plugin.json   # GitHub Copilot location (highest priority)
+├── agents/
+│   └── agent-name.agent.md
+├── skills/
+│   └── skill-name/
+│       └── SKILL.md
+└── commands/
+    └── command-1.md
+    └── command-2.md
+```
+
+#### Claude Format
+```
+plugin-repo/
+├── .claude-plugin/
+│   └── plugin.json       # Claude location (second priority)
+├── agents/
+│   └── agent-name.agent.md
+├── skills/
+│   └── skill-name/
+│       └── SKILL.md
+└── commands/
+    └── command-1.md
+    └── command-2.md
+```
+
+#### Legacy APM Format
+```
+plugin-repo/
+├── plugins/
+│   └── plugin.json       # Legacy APM location (third priority)
+├── agents/
+│   └── agent-name.agent.md
+├── skills/
+│   └── skill-name/
+│       └── SKILL.md
+└── commands/
+    └── command-1.md
+    └── command-2.md
+```
+
+#### Root Format
+```
+plugin-repo/
+├── plugin.json           # Root location (lowest priority)
+├── agents/
+│   └── agent-name.agent.md
+├── skills/
+│   └── skill-name/
+│       └── SKILL.md
+└── commands/
+    └── command-1.md
+    └── command-2.md
+```
+
+**Priority Order**: APM searches for `plugin.json`:
+1. `plugin.json` (root) - checked first
+2. Then recursively in subdirectories (e.g., `.github/plugin/`, `.claude-plugin/`)
+
+**Note**: Primitives (agents, skills, commands, instructions) are always located at the repository root, regardless of where `plugin.json` is located.
+
+### plugin.json Manifest
+
+Required fields:
+
+```json
+{
+  "name": "Plugin Display Name",
+  "version": "1.0.0",
+  "description": "What this plugin does"
+}
+```
+
+Optional fields:
+
+```json
+{
+  "name": "My Plugin",
+  "version": "1.0.0",
+  "description": "A plugin for APM",
+  "author": "Author Name",
+  "license": "MIT",
+  "repository": "owner/repo",
+  "homepage": "https://example.com",
+  "tags": ["ai", "coding"],
+  "dependencies": [
+    "another-plugin-id"
+  ]
+}
+```
+
+## Examples
+
+### Installing Plugins from GitHub
+
+```bash
+# Install a specific plugin
+apm install anthropics/claude-code-plugins/commit-commands
+
+# With version
+apm install anthropics/claude-code-plugins/commit-commands#v1.2.0
+```
+
+### Adding Multiple Plugins to apm.yml
+
+```yaml
+dependencies:
+  apm:
+    - anthropics/claude-code-plugins/commit-commands#v1.2.0
+    - anthropics/claude-code-plugins/refactor-tools#v2.0
+    - mycompany/internal-standards#main
+```
+
+Then sync and install:
+
+```bash
+apm install
+```
+
+### Version Management
+
+Plugins support all standard APM versioning:
+
+```yaml
+dependencies:
+  apm:
+    # Latest version
+    - owner/repo/plugin
+
+    # Latest from branch
+    - owner/repo/plugin#main
+
+    # Specific tag
+    - owner/repo/plugin#v1.2.0
+
+    # Specific commit  
+    - owner/repo/plugin#abc123
+```
+
+Run `apm install` to download and lock versions in `apm.lock`.
+
+## Supported Hosts
+
+- **GitHub** - `owner/repo` or `owner/repo/plugin-path`
+- **GitHub** - GitHub URLs or SSH references
+- **Azure DevOps** - `dev.azure.com/org/project/repo`
+
+## Lock File Integration
+
+Plugin versions are automatically tracked in `apm.lock`:
+
+```yaml
+apm_modules:
+  anthropics/claude-code-plugins/commit-commands:
+    resolved: https://github.com/anthropics/claude-code-plugins/commit-commands#v1.2.0
+    commit: abc123def456789
+```
+
+This ensures reproducible installs across environments.
+
+## Conflict Detection
+
+APM automatically detects:
+
+- Duplicate plugins from different sources
+- Version conflicts between dependencies
+- Missing transitive dependencies
+
+Run with `--verbose` to see dependency resolution details:
+
+```bash
+apm install --verbose
+```
+
+## Compilation
+
+Plugins are automatically compiled during `apm compile`:
+
+```bash
+apm compile
+```
+
+This:
+- Generates `AGENTS.md` from plugin agents
+- Integrates skills into the runtime
+- Includes prompt primitives
+
+## Finding Plugins
+
+Plugins can be found through:
+- GitHub repositories (search for repos with `plugin.json`)
+- Organization-specific plugin repositories
+- Community plugin collections
+
+Once found, install them using the standard `apm install owner/repo/plugin-name` command.
+
+## Troubleshooting
+
+### Plugin Not Detected
+
+If APM doesn't recognize your plugin:
+
+1. Check `plugin.json` exists at the repository root or in a subdirectory:
+   - `plugin.json` (root )
+   - `.github/plugin/plugin.json` (GitHub Copilot format)
+   - `.claude-plugin/plugin.json` (Claude format)
+2. Verify JSON is valid: `cat plugin.json | jq .`
+3. Ensure required fields are present: `name`, `version`, `description`
+4. Verify primitives are at the repository root (`agents/`, `skills/`, `commands/`)
+
+### Version Resolution Issues
+
+See the [concepts.md](./concepts.md) guide on dependency resolution.
+
+### Custom Hosts / Private Repositories
+
+See [integration-testing.md](./integration-testing.md) for enterprise setup.
diff --git a/src/apm_cli/cli.py b/src/apm_cli/cli.py
index d69493371..fdd8e6995 100644
--- a/src/apm_cli/cli.py
+++ b/src/apm_cli/cli.py
@@ -1164,7 +1164,7 @@ def uninstall(ctx, packages, dry_run):
         skills_failed = 0
 
         try:
-            from apm_cli.models.apm_package import APMPackage, PackageInfo, PackageType, validate_package
+            from apm_cli.models.apm_package import APMPackage, PackageInfo, PackageType, validate_apm_package
             from apm_cli.integration.prompt_integrator import PromptIntegrator
             from apm_cli.integration.agent_integrator import AgentIntegrator
             from apm_cli.integration.skill_integrator import SkillIntegrator
@@ -1212,7 +1212,7 @@ def uninstall(ctx, packages, dry_run):
                     continue
 
                 # Build minimal PackageInfo for re-integration
-                result = validate_package(install_path)
+                result = validate_apm_package(install_path)
                 pkg = result.package if result and result.package else None
                 if not pkg:
                     continue
@@ -1728,6 +1728,10 @@ def matches_filter(dep):
                             _rich_info(
                                 f"  └─ Package type: Skill (SKILL.md detected)"
                             )
+                        elif package_type == PackageType.MARKETPLACE_PLUGIN:
+                            _rich_info(
+                                f"  └─ Package type: Marketplace Plugin (plugin.json detected)"
+                            )
                         elif package_type == PackageType.HYBRID:
                             _rich_info(
                                 f"  └─ Package type: Hybrid (apm.yml + SKILL.md)"
diff --git a/src/apm_cli/deps/github_downloader.py b/src/apm_cli/deps/github_downloader.py
index a7286d007..4cc1e2320 100644
--- a/src/apm_cli/deps/github_downloader.py
+++ b/src/apm_cli/deps/github_downloader.py
@@ -610,7 +610,7 @@ def validate_virtual_package_exists(self, dep_ref: DependencyReference) -> bool:
         Supports:
         - Virtual files: owner/repo/path/file.prompt.md
         - Collections: owner/repo/collections/name (checks for .collection.yml)
-        - Subdirectory packages: owner/repo/path/subdir (checks for apm.yml or SKILL.md)
+        - Subdirectory packages: owner/repo/path/subdir (checks for apm.yml, SKILL.md, or plugin.json)
         
         Args:
             dep_ref: Parsed dependency reference for virtual package
@@ -641,7 +641,7 @@ def validate_virtual_package_exists(self, dep_ref: DependencyReference) -> bool:
             except RuntimeError:
                 return False
         
-        # For subdirectory packages, check for apm.yml or SKILL.md
+        # For subdirectory packages, check for apm.yml, SKILL.md, or plugin.json
         if dep_ref.is_virtual_subdirectory():
             # Try apm.yml first
             try:
@@ -657,7 +657,20 @@ def validate_virtual_package_exists(self, dep_ref: DependencyReference) -> bool:
             except RuntimeError:
                 pass
             
-            return False
+            # Try plugin.json at various marketplace locations
+            # This allows for new plugin formats to be added easily (e.g., .codex, .assistant, etc.)
+            plugin_locations = [
+                f"{dep_ref.virtual_path}/plugin.json",                                    # Root
+                f"{dep_ref.virtual_path}/.github/plugin/plugin.json",                     # GitHub Copilot format
+                f"{dep_ref.virtual_path}/.claude-plugin/plugin.json",                     # Claude format
+            ]
+            
+            for plugin_path in plugin_locations:
+                try:
+                    self.download_raw_file(dep_ref, plugin_path, ref)
+                    return True
+                except RuntimeError:
+                    continue
         
         # Fallback: try to download the file directly
         try:
diff --git a/src/apm_cli/deps/plugin_parser.py b/src/apm_cli/deps/plugin_parser.py
new file mode 100644
index 000000000..29a166fac
--- /dev/null
+++ b/src/apm_cli/deps/plugin_parser.py
@@ -0,0 +1,178 @@
+"""Parser for marketplace plugins (plugin.json format).
+
+This module handles parsing plugin.json files and synthesizing apm.yml
+from marketplace plugin metadata.
+"""
+
+import json
+import shutil
+from pathlib import Path
+from typing import Dict, Any, Optional
+import yaml
+
+
+def parse_plugin_manifest(plugin_json_path: Path) -> Dict[str, Any]:
+    """Parse a plugin.json manifest file.
+    
+    Args:
+        plugin_json_path: Path to the plugin.json file
+        
+    Returns:
+        dict: Parsed plugin manifest
+        
+    Raises:
+        FileNotFoundError: If plugin.json does not exist
+        ValueError: If plugin.json is invalid JSON
+    """
+    if not plugin_json_path.exists():
+        raise FileNotFoundError(f"plugin.json not found: {plugin_json_path}")
+    
+    try:
+        with open(plugin_json_path, 'r', encoding='utf-8') as f:
+            manifest = json.load(f)
+    except json.JSONDecodeError as e:
+        raise ValueError(f"Invalid JSON in plugin.json: {e}")
+    
+    return manifest
+
+
+def synthesize_apm_yml_from_plugin(plugin_path: Path, manifest: Dict[str, Any]) -> Path:
+    """Synthesize apm.yml from plugin.json metadata.
+    
+    Maps the plugin's agents/, skills/, commands/ directories into .apm/
+    structure and generates apm.yml with the plugin metadata.
+    
+    Args:
+        plugin_path: Path to the plugin directory
+        manifest: Parsed plugin.json manifest
+        
+    Returns:
+        Path: Path to the generated apm.yml
+        
+    Raises:
+        ValueError: If manifest is missing required fields
+    """
+    # Validate required fields
+    required_fields = ['name', 'version', 'description']
+    missing = [f for f in required_fields if f not in manifest]
+    if missing:
+        raise ValueError(f"plugin.json missing required fields: {', '.join(missing)}")
+    
+    # Create .apm directory structure
+    apm_dir = plugin_path / ".apm"
+    apm_dir.mkdir(exist_ok=True)
+    
+    # Map plugin structure into .apm/ subdirectories
+    _map_plugin_artifacts(plugin_path, apm_dir, manifest)
+    
+    # Generate apm.yml from plugin metadata
+    apm_yml_content = _generate_apm_yml(manifest)
+    apm_yml_path = plugin_path / "apm.yml"
+    
+    with open(apm_yml_path, 'w', encoding='utf-8') as f:
+        f.write(apm_yml_content)
+    
+    return apm_yml_path
+
+
+def _map_plugin_artifacts(plugin_path: Path, apm_dir: Path, manifest: Dict[str, Any]) -> None:
+    """Map plugin artifacts (agents, skills, commands) to .apm/ subdirectories.
+    
+    Args:
+        plugin_path: Path to the plugin directory
+        apm_dir: Path to the .apm/ directory
+        manifest: Plugin manifest (used to understand plugin structure)
+    """
+    # Map agents/
+    source_agents = plugin_path / "agents"
+    if source_agents.exists() and source_agents.is_dir():
+        target_agents = apm_dir / "agents"
+        if target_agents.exists():
+            shutil.rmtree(target_agents)
+        shutil.copytree(source_agents, target_agents)
+    
+    # Map skills/ and convert to .apm/skills/ structure
+    # Each skill subdirectory with SKILL.md goes to .apm/skills/skillname/
+    source_skills = plugin_path / "skills"
+    if source_skills.exists() and source_skills.is_dir():
+        target_skills = apm_dir / "skills"
+        if target_skills.exists():
+            shutil.rmtree(target_skills)
+        shutil.copytree(source_skills, target_skills)
+    
+    # Map commands/ to .apm/prompts/
+    # Plugin commands typically become prompts in APM
+    source_commands = plugin_path / "commands"
+    if source_commands.exists() and source_commands.is_dir():
+        target_prompts = apm_dir / "prompts"
+        if target_prompts.exists():
+            shutil.rmtree(target_prompts)
+        shutil.copytree(source_commands, target_prompts)
+
+
+def _generate_apm_yml(manifest: Dict[str, Any]) -> str:
+    """Generate apm.yml content from plugin.json metadata.
+    
+    Args:
+        manifest: Plugin manifest from plugin.json
+        
+    Returns:
+        str: YAML content for apm.yml
+    """
+    apm_package = {
+        'name': manifest.get('name'),
+        'version': manifest.get('version'),
+        'description': manifest.get('description'),
+    }
+    
+    # Add optional fields if present
+    if 'author' in manifest:
+        apm_package['author'] = manifest['author']
+    
+    if 'license' in manifest:
+        apm_package['license'] = manifest['license']
+    
+    if 'repository' in manifest:
+        apm_package['repository'] = manifest['repository']
+    
+    if 'homepage' in manifest:
+        apm_package['homepage'] = manifest['homepage']
+    
+    if 'tags' in manifest:
+        apm_package['tags'] = manifest['tags']
+    
+    # Add dependencies if present
+    if 'dependencies' in manifest and manifest['dependencies']:
+        apm_package['dependencies'] = {
+            'apm': manifest['dependencies']
+        }
+    
+    # Add type as 'hybrid' to indicate it came from a marketplace plugin
+    apm_package['type'] = 'hybrid'
+    
+    # Generate YAML directly (no apm: wrapper needed)
+    return yaml.dump(apm_package, default_flow_style=False, sort_keys=False)
+
+
+def validate_plugin_package(plugin_path: Path) -> bool:
+    """Validate that a plugin package has required structure.
+    
+    Args:
+        plugin_path: Path to the plugin directory
+        
+    Returns:
+        bool: True if plugin.json exists and is valid
+    """
+    plugin_json = plugin_path / "plugin.json"
+    if not plugin_json.exists():
+        return False
+    
+    try:
+        with open(plugin_json, 'r', encoding='utf-8') as f:
+            manifest = json.load(f)
+        
+        # Check for required fields
+        required = ['name', 'version', 'description']
+        return all(field in manifest for field in required)
+    except (json.JSONDecodeError, IOError):
+        return False
diff --git a/src/apm_cli/models/apm_package.py b/src/apm_cli/models/apm_package.py
index 31c421498..872b6d511 100644
--- a/src/apm_cli/models/apm_package.py
+++ b/src/apm_cli/models/apm_package.py
@@ -21,12 +21,13 @@ class PackageType(Enum):
     """Types of packages that APM can install.
     
     This enum is used internally to classify packages based on their content
-    (presence of apm.yml, SKILL.md, etc.).
+    (presence of apm.yml, SKILL.md, plugin.json, etc.).
     """
     APM_PACKAGE = "apm_package"      # Has apm.yml
     CLAUDE_SKILL = "claude_skill"    # Has SKILL.md, no apm.yml
     HYBRID = "hybrid"                # Has both apm.yml and SKILL.md
-    INVALID = "invalid"              # Neither apm.yml nor SKILL.md
+    MARKETPLACE_PLUGIN = "marketplace_plugin"  # Has plugin.json, no apm.yml
+    INVALID = "invalid"              # None of the above
 
 
 class PackageContentType(Enum):
@@ -884,9 +885,10 @@ def has_primitives(self) -> bool:
 def validate_apm_package(package_path: Path) -> ValidationResult:
     """Validate that a directory contains a valid APM package or Claude Skill.
     
-    Supports three package types:
+    Supports four package types:
     - APM_PACKAGE: Has apm.yml and .apm/ directory
-    - CLAUDE_SKILL: Has SKILL.md but no apm.yml (auto-generates apm.yml)
+    - CLAUDE_SKILL: Has SKILL.md but no apm.yml (auto-generates minimal apm.yml)
+    - MARKETPLACE_PLUGIN: Has plugin.json but no apm.yml (synthesizes apm.yml)
     - HYBRID: Has both apm.yml and SKILL.md
     
     Args:
@@ -909,25 +911,37 @@ def validate_apm_package(package_path: Path) -> ValidationResult:
     # Detect package type
     apm_yml_path = package_path / "apm.yml"
     skill_md_path = package_path / "SKILL.md"
+    
+    # Check for plugin.json using centralized helper
+    from ..utils.helpers import find_plugin_json
+    plugin_json_path = find_plugin_json(package_path)
+    
     has_apm_yml = apm_yml_path.exists()
     has_skill_md = skill_md_path.exists()
+    has_plugin_json = plugin_json_path is not None
     
-    # Determine package type
+    # Determine package type (apm.yml takes precedence)
     if has_apm_yml and has_skill_md:
         result.package_type = PackageType.HYBRID
     elif has_apm_yml:
         result.package_type = PackageType.APM_PACKAGE
     elif has_skill_md:
         result.package_type = PackageType.CLAUDE_SKILL
+    elif has_plugin_json:
+        result.package_type = PackageType.MARKETPLACE_PLUGIN
     else:
         result.package_type = PackageType.INVALID
-        result.add_error("Missing required file: apm.yml or SKILL.md")
+        result.add_error("Missing required file: apm.yml, SKILL.md, or plugin.json")
         return result
     
     # Handle Claude Skills (no apm.yml) - auto-generate minimal apm.yml
     if result.package_type == PackageType.CLAUDE_SKILL:
         return _validate_claude_skill(package_path, skill_md_path, result)
     
+    # Handle Marketplace Plugins (no apm.yml) - synthesize apm.yml from plugin.json
+    if result.package_type == PackageType.MARKETPLACE_PLUGIN:
+        return _validate_marketplace_plugin(package_path, plugin_json_path, result)
+    
     # Standard APM package validation (has apm.yml)
     return _validate_apm_package_with_yml(package_path, apm_yml_path, result)
 
@@ -972,6 +986,42 @@ def _validate_claude_skill(package_path: Path, skill_md_path: Path, result: Vali
     return result
 
 
+def _validate_marketplace_plugin(package_path: Path, plugin_json_path: Path, result: ValidationResult) -> ValidationResult:
+    """Validate a marketplace plugin and synthesize apm.yml from plugin.json.
+    
+    Args:
+        package_path: Path to the package directory
+        plugin_json_path: Path to plugin.json
+        result: ValidationResult to populate
+        
+    Returns:
+        ValidationResult: Updated validation result with MARKETPLACE_PLUGIN type
+    """
+    from ..deps.plugin_parser import parse_plugin_manifest, synthesize_apm_yml_from_plugin
+    
+    try:
+        # Parse plugin.json manifest
+        manifest = parse_plugin_manifest(plugin_json_path)
+        
+        # Synthesize apm.yml from plugin metadata
+        apm_yml_path = synthesize_apm_yml_from_plugin(package_path, manifest)
+        
+        # Now validate the synthesized apm.yml
+        package = APMPackage.from_apm_yml(apm_yml_path)
+        result.package = package
+        # Keep the MARKETPLACE_PLUGIN type to track that it came from plugin.json
+        result.package_type = PackageType.MARKETPLACE_PLUGIN
+        
+    except (ValueError, FileNotFoundError) as e:
+        result.add_error(f"Invalid plugin.json: {e}")
+        return result
+    except Exception as e:
+        result.add_error(f"Failed to process marketplace plugin: {e}")
+        return result
+    
+    return result
+
+
 def _validate_apm_package_with_yml(package_path: Path, apm_yml_path: Path, result: ValidationResult) -> ValidationResult:
     """Validate a standard APM package with apm.yml.
     
diff --git a/src/apm_cli/models/plugin.py b/src/apm_cli/models/plugin.py
new file mode 100644
index 000000000..f87991daf
--- /dev/null
+++ b/src/apm_cli/models/plugin.py
@@ -0,0 +1,202 @@
+"""Plugin management data models."""
+
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import List, Optional, Dict, Any
+import json
+
+
+@dataclass
+class PluginMetadata:
+    """Metadata for a plugin.
+    
+    Attributes:
+        id: Unique plugin identifier (e.g., "awesome-copilot")
+        name: Human-readable plugin name
+        version: Semantic version string
+        description: Short description of the plugin
+        author: Plugin author name or organization
+        repository: Repository reference (e.g., "owner/repo" or "dev.azure.com/org/project/repo")
+        homepage: Optional homepage URL
+        license: Optional license identifier (e.g., "MIT", "Apache-2.0")
+        tags: List of tags for categorization
+        dependencies: List of plugin dependencies (plugin IDs)
+    """
+    id: str
+    name: str
+    version: str
+    description: str
+    author: str
+    repository: Optional[str] = None
+    homepage: Optional[str] = None
+    license: Optional[str] = None
+    tags: List[str] = field(default_factory=list)
+    dependencies: List[str] = field(default_factory=list)
+
+    def to_dict(self) -> Dict[str, Any]:
+        """Convert metadata to dictionary."""
+        return {
+            "id": self.id,
+            "name": self.name,
+            "version": self.version,
+            "description": self.description,
+            "author": self.author,
+            "repository": self.repository,
+            "homepage": self.homepage,
+            "license": self.license,
+            "tags": self.tags,
+            "dependencies": self.dependencies,
+        }
+
+    @classmethod
+    def from_dict(cls, data: Dict[str, Any]) -> "PluginMetadata":
+        """Create metadata from dictionary."""
+        return cls(
+            id=data["id"],
+            name=data["name"],
+            version=data["version"],
+            description=data["description"],
+            author=data["author"],
+            repository=data.get("repository"),
+            homepage=data.get("homepage"),
+            license=data.get("license"),
+            tags=data.get("tags", []),
+            dependencies=data.get("dependencies", []),
+        )
+
+
+@dataclass
+class Plugin:
+    """Represents an installed plugin.
+    
+    Attributes:
+        metadata: Plugin metadata
+        path: Path to the plugin directory
+        commands: List of command file paths
+        agents: List of agent file paths (*.agent.md)
+        hooks: List of hook script paths
+        skills: List of skill file paths (*.skill.md)
+    """
+    metadata: PluginMetadata
+    path: Path
+    commands: List[Path] = field(default_factory=list)
+    agents: List[Path] = field(default_factory=list)
+    hooks: List[Path] = field(default_factory=list)
+    skills: List[Path] = field(default_factory=list)
+
+    @classmethod
+    def from_path(cls, plugin_path: Path) -> "Plugin":
+        """Load a plugin from its installation directory.
+        
+        Plugin structure: plugin.json can be in .github/plugin/, .claude-plugin/, plugins/, or root.
+        Primitives (agents, skills, etc.) are always at the repository root.
+        
+        Args:
+            plugin_path: Path to the plugin directory
+            
+        Returns:
+            Plugin: The loaded plugin instance
+            
+        Raises:
+            FileNotFoundError: If plugin.json is not found
+            ValueError: If plugin.json is invalid
+        """
+        # Find plugin.json using centralized helper
+        from ..utils.helpers import find_plugin_json
+        metadata_file = find_plugin_json(plugin_path)
+        
+        if metadata_file is None:
+            raise FileNotFoundError(f"Plugin metadata not found in any expected location: {plugin_path}")
+        
+        with open(metadata_file, "r") as f:
+            metadata_dict = json.load(f)
+        
+        metadata = PluginMetadata.from_dict(metadata_dict)
+        
+        # Primitives are always at the repository root
+        base_dir = plugin_path
+        
+        # Discover plugin components in plugins/ subdirectory (including subdirectories)
+        commands = list((base_dir / "commands").rglob("*.py")) if (base_dir / "commands").exists() else []
+        agents = list((base_dir / "agents").rglob("*.agent.md")) if (base_dir / "agents").exists() else []
+        hooks = list((base_dir / "hooks").rglob("*.py")) if (base_dir / "hooks").exists() else []
+        
+        # Skills: each subdirectory in skills/ must contain a SKILL.md
+        skills = []
+        skills_dir = base_dir / "skills"
+        if skills_dir.exists():
+            for skill_subdir in skills_dir.iterdir():
+                if skill_subdir.is_dir():
+                    skill_file = skill_subdir / "SKILL.md"
+                    if skill_file.exists():
+                        skills.append(skill_file)
+        
+        return cls(
+            metadata=metadata,
+            path=plugin_path,
+            commands=commands,
+            agents=agents,
+            hooks=hooks,
+            skills=skills,
+        )
+
+
+@dataclass
+class MarketplaceEntry:
+    """Entry in the plugin marketplace.
+    
+    Attributes:
+        id: Unique plugin identifier
+        name: Human-readable plugin name
+        description: Short description
+        repository: Repository reference (e.g., "owner/repo" or "org/project/repo")
+        version: Latest version string
+        author: Plugin author
+        host: Optional host/platform ("github", "ado", or custom URL like "dev.azure.com")
+        tags: List of tags for categorization
+        downloads: Number of downloads (from marketplace)
+        stars: Number of stars (from marketplace)
+    """
+    id: str
+    name: str
+    description: str
+    repository: str
+    version: str
+    author: str
+    host: Optional[str] = None
+    tags: List[str] = field(default_factory=list)
+    downloads: int = 0
+    stars: int = 0
+
+    @classmethod
+    def from_dict(cls, data: Dict[str, Any]) -> "MarketplaceEntry":
+        """Create a marketplace entry from dictionary."""
+        return cls(
+            id=data["id"],
+            name=data.get("name", data["id"]),
+            description=data.get("description", ""),
+            repository=data["repository"],
+            version=data.get("version", "latest"),
+            author=data.get("author", ""),
+            host=data.get("host"),
+            tags=data.get("tags", []),
+            downloads=data.get("downloads", 0),
+            stars=data.get("stars", 0),
+        )
+
+    def to_dict(self) -> Dict[str, Any]:
+        """Convert to dictionary."""
+        result = {
+            "id": self.id,
+            "name": self.name,
+            "description": self.description,
+            "repository": self.repository,
+            "version": self.version,
+            "author": self.author,
+            "tags": self.tags,
+            "downloads": self.downloads,
+            "stars": self.stars,
+        }
+        if self.host:
+            result["host"] = self.host
+        return result
diff --git a/src/apm_cli/primitives/discovery.py b/src/apm_cli/primitives/discovery.py
index bab4eed31..dc026318b 100644
--- a/src/apm_cli/primitives/discovery.py
+++ b/src/apm_cli/primitives/discovery.py
@@ -88,6 +88,7 @@ def discover_primitives_with_dependencies(base_dir: str = ".") -> PrimitiveColle
     Priority Order:
     1. Local .apm/ (highest priority - always wins)
     2. Dependencies in declaration order (first declared wins)
+    3. Plugins (lowest priority)
     
     Args:
         base_dir (str): Base directory to search in. Defaults to current directory.
@@ -106,6 +107,9 @@ def discover_primitives_with_dependencies(base_dir: str = ".") -> PrimitiveColle
     # Phase 2: Dependency primitives (lower priority, with conflict detection)
     scan_dependency_primitives(base_dir, collection)
     
+    # Phase 3: Plugin primitives (lowest priority)
+    scan_plugin_primitives(base_dir, collection)
+    
     return collection
 
 
@@ -277,6 +281,88 @@ def scan_directory_with_source(directory: Path, collection: PrimitiveCollection,
     _discover_skill_in_directory(directory, collection, source)
 
 
+def scan_plugin_primitives(base_dir: str, collection: PrimitiveCollection) -> None:
+    """Scan apm_modules/ directory for plugins with primitives (agents, skills, instructions).
+    
+    This function discovers primitives from installed plugins in the apm_modules/ directory.
+    Plugins are identified by the presence of a plugin.json file. Plugins have the lowest 
+    priority in the primitive resolution order.
+    
+    Plugin structure: apm_modules/{owner}/{repo}/plugins/
+    
+    Args:
+        base_dir (str): Base directory to search in.
+        collection (PrimitiveCollection): Collection to add primitives to.
+    """
+    apm_modules_path = Path(base_dir) / "apm_modules"
+    if not apm_modules_path.exists():
+        return
+    
+    # Iterate through all directories in apm_modules/{owner}/{repo}/ structure
+    for owner_dir in apm_modules_path.iterdir():
+        if not owner_dir.is_dir():
+            continue
+        
+        for repo_dir in owner_dir.iterdir():
+            if not repo_dir.is_dir():
+                continue
+            
+            # Check if this is a plugin (has plugin.json in any supported location)
+            from ..utils.helpers import find_plugin_json
+            active_plugin_json = find_plugin_json(repo_dir)
+            
+            if active_plugin_json is None:
+                continue
+            
+            # Get plugin ID from plugin.json
+            try:
+                import json
+                with open(active_plugin_json) as f:
+                    plugin_data = json.load(f)
+                    plugin_id = plugin_data.get("id", repo_dir.name)
+            except (json.JSONDecodeError, OSError):
+                plugin_id = repo_dir.name
+            
+            source = f"plugin:{plugin_id}"
+            
+            # Plugin primitives are always at the repository root
+            # (not in the folder containing plugin.json)
+            base_dir = repo_dir
+            
+            # Scan agents/ (including subdirectories)
+            agents_dir = base_dir / "agents"
+            if agents_dir.exists():
+                for agent_file in agents_dir.rglob("*.agent.md"):
+                    if _is_readable(agent_file):
+                        try:
+                            primitive = parse_primitive_file(agent_file, source=source)
+                            collection.add_primitive(primitive)
+                        except Exception as e:
+                            print(f"Warning: Failed to parse plugin agent {agent_file}: {e}")
+            
+            # Scan skills/ subdirectories
+            # Each subdirectory in skills/ must contain a SKILL.md
+            skills_dir = base_dir / "skills"
+            if skills_dir.exists():
+                for skill_subdir in skills_dir.iterdir():
+                    if skill_subdir.is_dir():
+                        _discover_skill_in_directory(skill_subdir, collection, source)
+            
+            # Scan instructions/ (including subdirectories)
+            instructions_dir = base_dir / "instructions"
+            if instructions_dir.exists():
+                for inst_file in instructions_dir.rglob("*.instructions.md"):
+                    if _is_readable(inst_file):
+                        try:
+                            primitive = parse_primitive_file(inst_file, source=source)
+                            collection.add_primitive(primitive)
+                        except Exception as e:
+                            print(f"Warning: Failed to parse plugin instruction {inst_file}: {e}")
+            
+            # Also check for SKILL.md in the root directory
+            _discover_skill_in_directory(base_dir, collection, source)
+
+
 def _discover_local_skill(base_dir: str, collection: PrimitiveCollection) -> None:
     """Discover SKILL.md at the project root.
     
diff --git a/src/apm_cli/utils/console.py b/src/apm_cli/utils/console.py
index 5a96cb52f..333f7e56b 100644
--- a/src/apm_cli/utils/console.py
+++ b/src/apm_cli/utils/console.py
@@ -48,7 +48,10 @@
     'default': '📍',  # Default script marker
     'eyes': '👀',  # Watch mode
     'folder': '📁',  # Directory/folder operations
-    'cogs': '⚙️'  # Compilation/processing
+    'cogs': '⚙️',  # Compilation/processing
+    'plugin': '🔌',  # Plugin-related operations
+    'search': '🔍',  # Search operations
+    'download': '📥',  # Download operations
 }
 
 
diff --git a/src/apm_cli/utils/helpers.py b/src/apm_cli/utils/helpers.py
index 78043abba..3dfddbd15 100644
--- a/src/apm_cli/utils/helpers.py
+++ b/src/apm_cli/utils/helpers.py
@@ -5,6 +5,8 @@
 import subprocess
 import shutil
 import sys
+from pathlib import Path
+from typing import Optional
 
 
 def is_tool_available(tool_name):
@@ -99,3 +101,24 @@ def detect_platform():
         return "windows"
     else:
         return "unknown"
+
+
+def find_plugin_json(plugin_path: Path) -> Optional[Path]:
+    """Find plugin.json in a plugin directory.
+    
+    Searches for plugin.json first at the root, then recursively in subdirectories.
+    
+    Args:
+        plugin_path: Path to the plugin directory
+        
+    Returns:
+        Optional[Path]: Path to the plugin.json file if found, None otherwise
+    """
+    # Check root first (fastest)
+    root_plugin = plugin_path / "plugin.json"
+    if root_plugin.exists():
+        return root_plugin
+    
+    # Fallback to recursive search in subdirectories
+    matches = list(plugin_path.glob("**/plugin.json"))
+    return matches[0] if matches else None
diff --git a/tests/fixtures/mock-claude-plugin/.claude-plugin/plugin.json b/tests/fixtures/mock-claude-plugin/.claude-plugin/plugin.json
new file mode 100644
index 000000000..6f8855d48
--- /dev/null
+++ b/tests/fixtures/mock-claude-plugin/.claude-plugin/plugin.json
@@ -0,0 +1,11 @@
+{
+  "name": "Mock Marketplace Plugin",
+  "version": "1.0.0",
+  "description": "A test marketplace plugin for APM integration testing",
+  "author": "APM Test Suite",
+  "license": "MIT",
+  "repository": "microsoft/apm-test-plugin",
+  "homepage": "https://github.com/microsoft/apm",
+  "tags": ["testing", "example", "marketplace"],
+  "dependencies": []
+}
diff --git a/tests/fixtures/mock-claude-plugin/agents/test-agent.agent.md b/tests/fixtures/mock-claude-plugin/agents/test-agent.agent.md
new file mode 100644
index 000000000..e1aebcffb
--- /dev/null
+++ b/tests/fixtures/mock-claude-plugin/agents/test-agent.agent.md
@@ -0,0 +1,8 @@
+# Test Agent
+
+This is a test agent from a marketplace plugin.
+
+## Capabilities
+
+- Testing plugin integration
+- Validating artifact mapping
diff --git a/tests/fixtures/mock-claude-plugin/commands/test-command.md b/tests/fixtures/mock-claude-plugin/commands/test-command.md
new file mode 100644
index 000000000..f719edb6f
--- /dev/null
+++ b/tests/fixtures/mock-claude-plugin/commands/test-command.md
@@ -0,0 +1,7 @@
+# Test Command
+
+A test command from a marketplace plugin.
+
+## Usage
+
+This command demonstrates how plugin commands are mapped to `.apm/prompts/`.
diff --git a/tests/fixtures/mock-claude-plugin/skills/test-skill/SKILL.md b/tests/fixtures/mock-claude-plugin/skills/test-skill/SKILL.md
new file mode 100644
index 000000000..6bbf92b49
--- /dev/null
+++ b/tests/fixtures/mock-claude-plugin/skills/test-skill/SKILL.md
@@ -0,0 +1,8 @@
+---
+name: Test Skill
+description: A test skill from a marketplace plugin
+---
+
+# Test Skill
+
+This skill demonstrates marketplace plugin integration.
diff --git a/tests/fixtures/mock-marketplace-plugin/.github/plugin/plugin.json b/tests/fixtures/mock-marketplace-plugin/.github/plugin/plugin.json
new file mode 100644
index 000000000..6f8855d48
--- /dev/null
+++ b/tests/fixtures/mock-marketplace-plugin/.github/plugin/plugin.json
@@ -0,0 +1,11 @@
+{
+  "name": "Mock Marketplace Plugin",
+  "version": "1.0.0",
+  "description": "A test marketplace plugin for APM integration testing",
+  "author": "APM Test Suite",
+  "license": "MIT",
+  "repository": "microsoft/apm-test-plugin",
+  "homepage": "https://github.com/microsoft/apm",
+  "tags": ["testing", "example", "marketplace"],
+  "dependencies": []
+}
diff --git a/tests/fixtures/mock-marketplace-plugin/agents/test-agent.agent.md b/tests/fixtures/mock-marketplace-plugin/agents/test-agent.agent.md
new file mode 100644
index 000000000..e1aebcffb
--- /dev/null
+++ b/tests/fixtures/mock-marketplace-plugin/agents/test-agent.agent.md
@@ -0,0 +1,8 @@
+# Test Agent
+
+This is a test agent from a marketplace plugin.
+
+## Capabilities
+
+- Testing plugin integration
+- Validating artifact mapping
diff --git a/tests/fixtures/mock-marketplace-plugin/commands/test-command.md b/tests/fixtures/mock-marketplace-plugin/commands/test-command.md
new file mode 100644
index 000000000..f719edb6f
--- /dev/null
+++ b/tests/fixtures/mock-marketplace-plugin/commands/test-command.md
@@ -0,0 +1,7 @@
+# Test Command
+
+A test command from a marketplace plugin.
+
+## Usage
+
+This command demonstrates how plugin commands are mapped to `.apm/prompts/`.
diff --git a/tests/fixtures/mock-marketplace-plugin/skills/test-skill/SKILL.md b/tests/fixtures/mock-marketplace-plugin/skills/test-skill/SKILL.md
new file mode 100644
index 000000000..6bbf92b49
--- /dev/null
+++ b/tests/fixtures/mock-marketplace-plugin/skills/test-skill/SKILL.md
@@ -0,0 +1,8 @@
+---
+name: Test Skill
+description: A test skill from a marketplace plugin
+---
+
+# Test Skill
+
+This skill demonstrates marketplace plugin integration.
diff --git a/tests/fixtures/mock-plugin/.apm/agents/example.agent.md b/tests/fixtures/mock-plugin/.apm/agents/example.agent.md
new file mode 100644
index 000000000..46ef0f0e3
--- /dev/null
+++ b/tests/fixtures/mock-plugin/.apm/agents/example.agent.md
@@ -0,0 +1,18 @@
+---
+description: Example agent from mock plugin
+role: code-reviewer
+---
+
+# Example Agent from Mock Plugin
+
+An example agent that demonstrates agent integration in plugins.
+
+## Capabilities
+
+- Code reviews
+- Design pattern assessment
+- Best practices validation
+
+## Context Sources
+
+Uses the mock plugin context to provide guidance.
diff --git a/tests/fixtures/mock-plugin/.apm/agents/helper.agent.md b/tests/fixtures/mock-plugin/.apm/agents/helper.agent.md
new file mode 100644
index 000000000..b43575c6d
--- /dev/null
+++ b/tests/fixtures/mock-plugin/.apm/agents/helper.agent.md
@@ -0,0 +1,14 @@
+---
+description: Helper agent for plugin functionality
+role: assistant
+---
+
+# Helper Agent
+
+A helper agent that assists with plugin-related tasks.
+
+## Functions
+
+- Provides guidance
+- Answers questions
+- Supports troubleshooting
diff --git a/tests/fixtures/mock-plugin/.apm/hooks/setup.sh b/tests/fixtures/mock-plugin/.apm/hooks/setup.sh
new file mode 100644
index 000000000..fa63dcae6
--- /dev/null
+++ b/tests/fixtures/mock-plugin/.apm/hooks/setup.sh
@@ -0,0 +1,11 @@
+#!/bin/bash
+
+# Setup hook for mock plugin
+# This hook runs during plugin installation to perform initialization tasks
+
+echo "Setting up mock plugin..."
+
+# Example: Create any necessary directories or files
+# Example: Run initialization logic
+
+echo "Mock plugin setup complete!"
diff --git a/tests/fixtures/mock-plugin/.apm/instructions/coding-standards.instructions.md b/tests/fixtures/mock-plugin/.apm/instructions/coding-standards.instructions.md
new file mode 100644
index 000000000..d6d8e18f9
--- /dev/null
+++ b/tests/fixtures/mock-plugin/.apm/instructions/coding-standards.instructions.md
@@ -0,0 +1,29 @@
+---
+applyTo: "**"
+description: "Coding standards for the mock plugin"
+---
+
+# Mock Plugin Coding Standards
+
+## Overview
+
+This is a mock plugin providing example coding standards and guidelines.
+
+## Code Quality
+
+- Follow PEP 8 for Python code
+- Use type hints in function signatures
+- Write docstrings for all public functions
+- Maintain test coverage above 80%
+
+## Documentation
+
+- Keep README.md up to date
+- Document breaking changes in CHANGELOG
+- Include examples for new features
+
+## Testing
+
+- Write tests for all new functionality
+- Run tests before submitting PR
+- Update test fixtures when changing code
diff --git a/tests/fixtures/mock-plugin/.apm/prompts/example.prompt.md b/tests/fixtures/mock-plugin/.apm/prompts/example.prompt.md
new file mode 100644
index 000000000..e467e5dbe
--- /dev/null
+++ b/tests/fixtures/mock-plugin/.apm/prompts/example.prompt.md
@@ -0,0 +1,18 @@
+---
+description: Example prompt from mock plugin
+tags: [example, mock]
+---
+
+# Example Prompt from Mock Plugin
+
+This is an example prompt from the mock plugin used in integration tests.
+
+## Usage
+
+Use this prompt when you need to demonstrate basic prompt functionality.
+
+## Context
+
+- Provides basic context structure
+- Demonstrates frontmatter metadata
+- Used for testing primitive integration
diff --git a/tests/fixtures/mock-plugin/.apm/prompts/setup.prompt.md b/tests/fixtures/mock-plugin/.apm/prompts/setup.prompt.md
new file mode 100644
index 000000000..8e849ccc0
--- /dev/null
+++ b/tests/fixtures/mock-plugin/.apm/prompts/setup.prompt.md
@@ -0,0 +1,18 @@
+---
+description: Setup prompt for plugin initialization
+tags: [setup, initialization]
+---
+
+# Plugin Setup Prompt
+
+This prompt handles plugin setup and initialization.
+
+## Steps
+
+1. Validate environment
+2. Configure settings
+3. Initialize resources
+
+## Note
+
+This is integrated during plugin installation.
diff --git a/tests/fixtures/mock-plugin/.apm/skills/example-skill/SKILL.md b/tests/fixtures/mock-plugin/.apm/skills/example-skill/SKILL.md
new file mode 100644
index 000000000..c36b245f6
--- /dev/null
+++ b/tests/fixtures/mock-plugin/.apm/skills/example-skill/SKILL.md
@@ -0,0 +1,20 @@
+---
+name: example-skill
+description: An example skill from the mock plugin
+version: 1.0.0
+author: Test Plugin
+---
+
+# Example Skill
+
+A sample skill integrated from the mock plugin for testing purposes.
+
+## What This Skill Does
+
+- Demonstrates skill integration in plugins
+- Shows how skills are organized in subdirectories
+- Tests the skill automation capabilities
+
+## How to Use
+
+This skill is available after plugin installation and can be invoked through the normal skill discovery mechanism.
diff --git a/tests/fixtures/mock-plugin/apm.yml b/tests/fixtures/mock-plugin/apm.yml
new file mode 100644
index 000000000..c7f79612a
--- /dev/null
+++ b/tests/fixtures/mock-plugin/apm.yml
@@ -0,0 +1,6 @@
+name: mock-plugin
+version: 1.0.0
+description: A mock plugin for testing purposes
+author: Test Author
+repository: https://github.com/test/mock-plugin
+plugins: []
diff --git a/tests/integration/test_marketplace_plugin_integration.py b/tests/integration/test_marketplace_plugin_integration.py
new file mode 100644
index 000000000..11f6d5f83
--- /dev/null
+++ b/tests/integration/test_marketplace_plugin_integration.py
@@ -0,0 +1,347 @@
+"""Integration test for plugin support.
+
+This test verifies the complete plugin workflow:
+1. Detection of plugin.json in various locations
+2. Synthesis of apm.yml from plugin.json metadata
+3. Artifact mapping to .apm/ structure
+4. Package validation and error handling
+"""
+
+import json
+import shutil
+from pathlib import Path
+import pytest
+from src.apm_cli.models.apm_package import validate_apm_package, PackageType
+
+
+class TestPluginIntegration:
+    """Test complete plugin integration."""
+    
+    def test_plugin_detection_and_synthesis(self, tmp_path):
+        """Test that plugin.json is detected and apm.yml is synthesized (root location)."""
+        plugin_dir = tmp_path / "test-plugin"
+        plugin_dir.mkdir()
+        
+        # Create plugin.json
+        plugin_json = {
+            "name": "Test Plugin",
+            "version": "1.0.0",
+            "description": "A test plugin",
+            "author": "Test Author",
+            "license": "MIT",
+            "tags": ["testing"]
+        }
+        
+        with open(plugin_dir / "plugin.json", "w") as f:
+            json.dump(plugin_json, f)
+        
+        # Create some plugin artifacts
+        (plugin_dir / "commands").mkdir()
+        (plugin_dir / "commands" / "test.md").write_text("# Test Command")
+        
+        # Run validation
+        result = validate_apm_package(plugin_dir)
+        
+        # Verify detection
+        assert result.package_type == PackageType.MARKETPLACE_PLUGIN
+        assert result.package is not None
+        assert result.package.name == "Test Plugin"
+        assert result.package.version == "1.0.0"
+        
+        # Verify synthesized apm.yml exists
+        apm_yml_path = plugin_dir / "apm.yml"
+        assert apm_yml_path.exists()
+        
+        # Verify .apm directory was created
+        apm_dir = plugin_dir / ".apm"
+        assert apm_dir.exists()
+    
+    def test_github_copilot_plugin_format(self, tmp_path):
+        """Test that .github/plugin/plugin.json format is detected."""
+        plugin_dir = tmp_path / "copilot-plugin"
+        plugin_dir.mkdir()
+        
+        # Create .github/plugin/plugin.json (GitHub Copilot format)
+        github_plugin_dir = plugin_dir / ".github" / "plugin"
+        github_plugin_dir.mkdir(parents=True)
+        
+        plugin_json = {
+            "name": "GitHub Copilot Plugin",
+            "version": "2.0.0",
+            "description": "A GitHub Copilot plugin"
+        }
+        
+        with open(github_plugin_dir / "plugin.json", "w") as f:
+            json.dump(plugin_json, f)
+        
+        # Create primitives at repository root
+        (plugin_dir / "agents").mkdir()
+        (plugin_dir / "agents" / "test.agent.md").write_text("# Test Agent")
+        
+        # Run validation
+        result = validate_apm_package(plugin_dir)
+        
+        # Verify detection
+        assert result.package_type == PackageType.MARKETPLACE_PLUGIN
+        assert result.package is not None
+        assert result.package.name == "GitHub Copilot Plugin"
+        assert result.package.version == "2.0.0"
+    
+    def test_claude_plugin_format(self, tmp_path):
+        """Test that .claude-plugin/plugin.json format is detected."""
+        plugin_dir = tmp_path / "claude-plugin"
+        plugin_dir.mkdir()
+        
+        # Create .claude-plugin/plugin.json (Claude format)
+        claude_plugin_dir = plugin_dir / ".claude-plugin"
+        claude_plugin_dir.mkdir(parents=True)
+        
+        plugin_json = {
+            "name": "Claude Plugin",
+            "version": "3.0.0",
+            "description": "A Claude plugin"
+        }
+        
+        with open(claude_plugin_dir / "plugin.json", "w") as f:
+            json.dump(plugin_json, f)
+        
+        # Create primitives at repository root
+        (plugin_dir / "skills").mkdir()
+        skill_dir = plugin_dir / "skills" / "test-skill"
+        skill_dir.mkdir()
+        (skill_dir / "SKILL.md").write_text("# Test Skill")
+        
+        # Run validation
+        result = validate_apm_package(plugin_dir)
+        
+        # Verify detection
+        assert result.package_type == PackageType.MARKETPLACE_PLUGIN
+        assert result.package is not None
+        assert result.package.name == "Claude Plugin"
+        assert result.package.version == "3.0.0"
+    
+    def test_plugin_location_priority(self, tmp_path):
+        """Test that plugin.json is found at root first, then recursively in subdirectories."""
+        # Test 1: Root plugin.json takes priority over subdirectories
+        plugin_dir = tmp_path / "priority-test"
+        plugin_dir.mkdir()
+        
+        with open(plugin_dir / "plugin.json", "w") as f:
+            json.dump({"name": "Root Plugin", "version": "1.0.0", "description": "Root"}, f)
+        
+        # Create in .claude-plugin/
+        (plugin_dir / ".claude-plugin").mkdir()
+        with open(plugin_dir / ".claude-plugin" / "plugin.json", "w") as f:
+            json.dump({"name": "Claude Plugin", "version": "3.0.0", "description": "Claude"}, f)
+        
+        # Create in .github/plugin/
+        (plugin_dir / ".github" / "plugin").mkdir(parents=True)
+        with open(plugin_dir / ".github" / "plugin" / "plugin.json", "w") as f:
+            json.dump({"name": "GitHub Plugin", "version": "4.0.0", "description": "GitHub"}, f)
+        
+        # Root should win
+        result = validate_apm_package(plugin_dir)
+        assert result.package_type == PackageType.MARKETPLACE_PLUGIN
+        assert result.package is not None
+        assert result.package.name == "Root Plugin"
+        assert result.package.version == "1.0.0"
+        
+        # Test 2: Recursive search finds .github/plugin/ when no root
+        plugin_dir2 = tmp_path / "github-test"
+        plugin_dir2.mkdir()
+        (plugin_dir2 / ".github" / "plugin").mkdir(parents=True)
+        with open(plugin_dir2 / ".github" / "plugin" / "plugin.json", "w") as f:
+            json.dump({"name": "GitHub Plugin", "version": "2.0.0", "description": "GitHub"}, f)
+            
+        result2 = validate_apm_package(plugin_dir2)
+        assert result2.package_type == PackageType.MARKETPLACE_PLUGIN
+        assert result2.package.name == "GitHub Plugin"
+        assert result2.package.version == "2.0.0"
+        
+        # Test 3: Recursive search finds .claude-plugin/ when no root
+        plugin_dir3 = tmp_path / "claude-test"
+        plugin_dir3.mkdir()
+        (plugin_dir3 / ".claude-plugin").mkdir()
+        with open(plugin_dir3 / ".claude-plugin" / "plugin.json", "w") as f:
+            json.dump({"name": "Claude Plugin", "version": "3.0.0", "description": "Claude"}, f)
+            
+        result3 = validate_apm_package(plugin_dir3)
+        assert result3.package_type == PackageType.MARKETPLACE_PLUGIN
+        assert result3.package.name == "Claude Plugin"
+        assert result3.package.version == "3.0.0"
+    
+    def test_plugin_detection_and_structure_mapping(self):
+        """Test that a plugin is detected and mapped correctly using fixtures."""
+        # Use the mock plugin fixture
+        fixture_path = Path(__file__).parent.parent / "fixtures" / "mock-marketplace-plugin"
+        
+        if not fixture_path.exists():
+            pytest.skip("Mock marketplace plugin fixture not available")
+        
+        # Validate the plugin package
+        result = validate_apm_package(fixture_path)
+        
+        # Verify package type detection
+        assert result.package_type == PackageType.MARKETPLACE_PLUGIN, \
+            f"Expected MARKETPLACE_PLUGIN, got {result.package_type}"
+        
+        # Verify no errors
+        assert result.is_valid, f"Package validation failed: {result.errors}"
+        
+        # Verify package was created
+        assert result.package is not None, "Package should be created"
+        assert result.package.name == "Mock Marketplace Plugin"
+        assert result.package.version == "1.0.0"
+        assert result.package.description == "A test marketplace plugin for APM integration testing"
+        
+        # Verify apm.yml was synthesized
+        apm_yml_path = fixture_path / "apm.yml"
+        assert apm_yml_path.exists(), "apm.yml should be synthesized"
+        
+        # Verify .apm directory structure was created
+        apm_dir = fixture_path / ".apm"
+        assert apm_dir.exists(), ".apm directory should exist"
+        
+        # Verify artifact mapping
+        agents_dir = apm_dir / "agents"
+        assert agents_dir.exists(), "agents/ should be mapped to .apm/agents/"
+        assert (agents_dir / "test-agent.agent.md").exists(), "Agent file should be mapped"
+        
+        skills_dir = apm_dir / "skills"
+        assert skills_dir.exists(), "skills/ should be mapped to .apm/skills/"
+        assert (skills_dir / "test-skill" / "SKILL.md").exists(), "Skill should be mapped"
+        
+        prompts_dir = apm_dir / "prompts"
+        assert prompts_dir.exists(), "commands/ should be mapped to .apm/prompts/"
+        assert (prompts_dir / "test-command.md").exists(), "Command should be mapped to prompts"
+        
+        # Cleanup synthesized files for next test run
+        if apm_yml_path.exists():
+            apm_yml_path.unlink()
+        if apm_dir.exists():
+            shutil.rmtree(apm_dir)
+    
+    def test_plugin_with_dependencies(self, tmp_path):
+        """Test plugin with dependencies are handled correctly."""
+        plugin_dir = tmp_path / "plugin-with-deps"
+        plugin_dir.mkdir()
+        
+        # Create plugin.json with dependencies
+        plugin_json = plugin_dir / "plugin.json"
+        plugin_json.write_text("""
+{
+  "name": "Plugin With Dependencies",
+  "version": "2.0.0",
+  "description": "A plugin with dependencies",
+  "author": "Test Author",
+  "dependencies": [
+    "owner/dependency-package",
+    "another/required-package#v1.0"
+  ]
+}
+""")
+        
+        # Validate
+        result = validate_apm_package(plugin_dir)
+        
+        assert result.package_type == PackageType.MARKETPLACE_PLUGIN
+        assert result.is_valid
+        assert result.package is not None
+        
+        # Verify dependencies are in apm.yml
+        apm_yml = plugin_dir / "apm.yml"
+        assert apm_yml.exists()
+        
+        content = apm_yml.read_text()
+        assert "dependencies:" in content
+        assert "owner/dependency-package" in content
+        assert "another/required-package#v1.0" in content
+    
+    def test_plugin_metadata_preservation(self, tmp_path):
+        """Test that all plugin metadata is preserved in apm.yml."""
+        plugin_dir = tmp_path / "metadata-plugin"
+        plugin_dir.mkdir()
+        
+        # Create plugin.json with all metadata fields
+        plugin_json = plugin_dir / "plugin.json"
+        plugin_json.write_text("""
+{
+  "name": "Full Metadata Plugin",
+  "version": "1.5.0",
+  "description": "A plugin with complete metadata",
+  "author": "APM Contributors",
+  "license": "Apache-2.0",
+  "repository": "microsoft/apm-plugin",
+  "homepage": "https://apm.dev/plugins/test",
+  "tags": ["ai", "agents", "testing"]
+}
+""")
+        
+        # Validate
+        result = validate_apm_package(plugin_dir)
+        
+        assert result.is_valid
+        package = result.package
+        
+        # Verify all metadata
+        assert package.name == "Full Metadata Plugin"
+        assert package.version == "1.5.0"
+        assert package.description == "A plugin with complete metadata"
+        assert package.author == "APM Contributors"
+        assert package.license == "Apache-2.0"
+        
+        # Read apm.yml and verify fields
+        apm_yml = (plugin_dir / "apm.yml").read_text()
+        assert "repository: microsoft/apm-plugin" in apm_yml
+        assert "homepage: https://apm.dev/plugins/test" in apm_yml
+        assert "tags:" in apm_yml
+        assert "ai" in apm_yml
+        assert "agents" in apm_yml
+    
+    def test_invalid_plugin_json(self, tmp_path):
+        """Test that invalid plugin.json is handled gracefully."""
+        plugin_dir = tmp_path / "invalid-plugin"
+        plugin_dir.mkdir()
+        
+        # Create invalid plugin.json (missing required fields)
+        plugin_json = plugin_dir / "plugin.json"
+        plugin_json.write_text("""
+{
+  "name": "Invalid Plugin"
+}
+""")
+        
+        # Validate
+        result = validate_apm_package(plugin_dir)
+        
+        # Should fail validation
+        assert not result.is_valid
+        assert len(result.errors) > 0
+        assert any("version" in err.lower() or "required" in err.lower() 
+                   for err in result.errors)
+    
+    def test_plugin_without_artifacts(self, tmp_path):
+        """Test plugin with only plugin.json and no artifacts."""
+        plugin_dir = tmp_path / "minimal-plugin"
+        plugin_dir.mkdir()
+        
+        # Create minimal plugin.json
+        plugin_json = plugin_dir / "plugin.json"
+        plugin_json.write_text("""
+{
+  "name": "Minimal Plugin",
+  "version": "0.1.0",
+  "description": "A minimal plugin"
+}
+""")
+        
+        # Validate
+        result = validate_apm_package(plugin_dir)
+        
+        assert result.package_type == PackageType.MARKETPLACE_PLUGIN
+        assert result.is_valid
+        assert result.package is not None
+        
+        # .apm directory should still be created even if empty
+        apm_dir = plugin_dir / ".apm"
+        assert apm_dir.exists()

From 15aa37c1e70f5b8a4cea3d36cb06d9c42f1285df Mon Sep 17 00:00:00 2001
From: SebastienDegodez <sebastien.degodez@gmail.com>
Date: Fri, 6 Mar 2026 16:53:47 +0100
Subject: [PATCH 2/8] fix: update validation logic for package types and error
 messages

---
 src/apm_cli/models/apm_package.py | 11 ++++-------
 1 file changed, 4 insertions(+), 7 deletions(-)

diff --git a/src/apm_cli/models/apm_package.py b/src/apm_cli/models/apm_package.py
index fb9bc7fe5..5e10168e5 100644
--- a/src/apm_cli/models/apm_package.py
+++ b/src/apm_cli/models/apm_package.py
@@ -1340,8 +1340,8 @@ def validate_apm_package(package_path: Path) -> ValidationResult:
     
     has_apm_yml = apm_yml_path.exists()
     has_skill_md = skill_md_path.exists()
-    has_plugin_json = plugin_json_path is not None
     has_hooks = _has_hook_json(package_path)
+    has_plugin_json = plugin_json_path is not None
     
     # Determine package type (apm.yml takes precedence)
     if has_apm_yml and has_skill_md:
@@ -1350,16 +1350,13 @@ def validate_apm_package(package_path: Path) -> ValidationResult:
         result.package_type = PackageType.APM_PACKAGE
     elif has_skill_md:
         result.package_type = PackageType.CLAUDE_SKILL
-    elif has_plugin_json:
-        result.package_type = PackageType.MARKETPLACE_PLUGIN
-    else:
-        result.package_type = PackageType.INVALID
-        result.add_error("Missing required file: apm.yml, SKILL.md, or plugin.json")
     elif has_hooks:
         result.package_type = PackageType.HOOK_PACKAGE
+    elif has_plugin_json:
+        result.package_type = PackageType.MARKETPLACE_PLUGIN
     else:
         result.package_type = PackageType.INVALID
-        result.add_error("Missing required file: apm.yml, SKILL.md, or hooks/*.json")
+        result.add_error("Missing required file: apm.yml, SKILL.md, plugin.json, or hooks/*.json")
         return result
     
     # Handle hook-only packages (no apm.yml or SKILL.md)

From ac5eb0de6dee138294c96215a4c30e4c5db960e5 Mon Sep 17 00:00:00 2001
From: SebastienDegodez <sebastien.degodez@gmail.com>
Date: Fri, 6 Mar 2026 18:13:30 +0100
Subject: [PATCH 3/8] feat: normalize command file extensions for prompt
 integration

---
 docs/plugins.md                               |  1 +
 src/apm_cli/deps/plugin_parser.py             | 19 +++-
 .../test_marketplace_plugin_integration.py    | 87 ++++++++++++++++---
 3 files changed, 94 insertions(+), 13 deletions(-)

diff --git a/docs/plugins.md b/docs/plugins.md
index fc7778556..8da18b573 100644
--- a/docs/plugins.md
+++ b/docs/plugins.md
@@ -40,6 +40,7 @@ When you run `apm install owner/repo/plugin-name`:
    - `agents/` → `.apm/agents/`
    - `skills/` → `.apm/skills/`
    - `commands/` → `.apm/prompts/`
+    - `*.md` command files are normalized to `*.prompt.md` for prompt/command integration
 4. **Synthesize** - `apm.yml` is automatically generated from plugin metadata
 5. **Integrate** - The plugin is now a standard dependency with:
    - Version pinning via `apm.lock`
diff --git a/src/apm_cli/deps/plugin_parser.py b/src/apm_cli/deps/plugin_parser.py
index 29a166fac..feb4f9522 100644
--- a/src/apm_cli/deps/plugin_parser.py
+++ b/src/apm_cli/deps/plugin_parser.py
@@ -107,7 +107,24 @@ def _map_plugin_artifacts(plugin_path: Path, apm_dir: Path, manifest: Dict[str,
         target_prompts = apm_dir / "prompts"
         if target_prompts.exists():
             shutil.rmtree(target_prompts)
-        shutil.copytree(source_commands, target_prompts)
+        target_prompts.mkdir(parents=True, exist_ok=True)
+
+        # Prompt integration looks for *.prompt.md files. Claude plugin commands
+        # are commonly *.md, so normalize them while preserving subdirectories.
+        for source_file in source_commands.rglob("*"):
+            if not source_file.is_file():
+                continue
+
+            relative_path = source_file.relative_to(source_commands)
+            target_path = target_prompts / relative_path
+
+            if source_file.name.endswith(".prompt.md"):
+                pass
+            elif source_file.suffix == ".md":
+                target_path = target_path.with_name(f"{source_file.stem}.prompt.md")
+
+            target_path.parent.mkdir(parents=True, exist_ok=True)
+            shutil.copy2(source_file, target_path)
 
 
 def _generate_apm_yml(manifest: Dict[str, Any]) -> str:
diff --git a/tests/integration/test_marketplace_plugin_integration.py b/tests/integration/test_marketplace_plugin_integration.py
index 11f6d5f83..48b5d0224 100644
--- a/tests/integration/test_marketplace_plugin_integration.py
+++ b/tests/integration/test_marketplace_plugin_integration.py
@@ -11,7 +11,20 @@
 import shutil
 from pathlib import Path
 import pytest
-from src.apm_cli.models.apm_package import validate_apm_package, PackageType
+from datetime import datetime
+
+from apm_cli.integration.agent_integrator import AgentIntegrator
+from apm_cli.integration.command_integrator import CommandIntegrator
+from apm_cli.integration.prompt_integrator import PromptIntegrator
+from apm_cli.integration.skill_integrator import SkillIntegrator
+from src.apm_cli.models.apm_package import (
+    APMPackage,
+    GitReferenceType,
+    PackageInfo,
+    PackageType,
+    ResolvedReference,
+    validate_apm_package,
+)
 
 
 class TestPluginIntegration:
@@ -170,7 +183,7 @@ def test_plugin_location_priority(self, tmp_path):
         assert result3.package.name == "Claude Plugin"
         assert result3.package.version == "3.0.0"
     
-    def test_plugin_detection_and_structure_mapping(self):
+    def test_plugin_detection_and_structure_mapping(self, tmp_path):
         """Test that a plugin is detected and mapped correctly using fixtures."""
         # Use the mock plugin fixture
         fixture_path = Path(__file__).parent.parent / "fixtures" / "mock-marketplace-plugin"
@@ -178,8 +191,11 @@ def test_plugin_detection_and_structure_mapping(self):
         if not fixture_path.exists():
             pytest.skip("Mock marketplace plugin fixture not available")
         
+        plugin_dir = tmp_path / "mock-marketplace-plugin"
+        shutil.copytree(fixture_path, plugin_dir)
+
         # Validate the plugin package
-        result = validate_apm_package(fixture_path)
+        result = validate_apm_package(plugin_dir)
         
         # Verify package type detection
         assert result.package_type == PackageType.MARKETPLACE_PLUGIN, \
@@ -195,11 +211,11 @@ def test_plugin_detection_and_structure_mapping(self):
         assert result.package.description == "A test marketplace plugin for APM integration testing"
         
         # Verify apm.yml was synthesized
-        apm_yml_path = fixture_path / "apm.yml"
+        apm_yml_path = plugin_dir / "apm.yml"
         assert apm_yml_path.exists(), "apm.yml should be synthesized"
         
         # Verify .apm directory structure was created
-        apm_dir = fixture_path / ".apm"
+        apm_dir = plugin_dir / ".apm"
         assert apm_dir.exists(), ".apm directory should exist"
         
         # Verify artifact mapping
@@ -213,13 +229,7 @@ def test_plugin_detection_and_structure_mapping(self):
         
         prompts_dir = apm_dir / "prompts"
         assert prompts_dir.exists(), "commands/ should be mapped to .apm/prompts/"
-        assert (prompts_dir / "test-command.md").exists(), "Command should be mapped to prompts"
-        
-        # Cleanup synthesized files for next test run
-        if apm_yml_path.exists():
-            apm_yml_path.unlink()
-        if apm_dir.exists():
-            shutil.rmtree(apm_dir)
+        assert (prompts_dir / "test-command.prompt.md").exists(), "Command should be mapped to prompts"
     
     def test_plugin_with_dependencies(self, tmp_path):
         """Test plugin with dependencies are handled correctly."""
@@ -345,3 +355,56 @@ def test_plugin_without_artifacts(self, tmp_path):
         # .apm directory should still be created even if empty
         apm_dir = plugin_dir / ".apm"
         assert apm_dir.exists()
+
+    def test_plugin_install_is_immediately_pickup_ready_for_vscode_and_copilot(self, tmp_path):
+        """Plugin install should populate .github/.claude targets consumed by editors."""
+        fixture_path = Path(__file__).parent.parent / "fixtures" / "mock-marketplace-plugin"
+        plugin_dir = tmp_path / "installed-plugin"
+        shutil.copytree(fixture_path, plugin_dir)
+
+        # Normalize plugin.json into apm.yml + .apm/
+        validation = validate_apm_package(plugin_dir)
+        assert validation.is_valid
+        assert validation.package_type == PackageType.MARKETPLACE_PLUGIN
+
+        package = validation.package
+        assert isinstance(package, APMPackage)
+
+        package_info = PackageInfo(
+            package=package,
+            install_path=plugin_dir,
+            resolved_reference=ResolvedReference(
+                original_ref="main",
+                ref_type=GitReferenceType.BRANCH,
+                resolved_commit="abcdef1234567890",
+                ref_name="main",
+            ),
+            installed_at=datetime.now().isoformat(),
+            package_type=validation.package_type,
+        )
+
+        project_root = tmp_path / "project"
+        project_root.mkdir()
+
+        prompt_result = PromptIntegrator().integrate_package_prompts(package_info, project_root)
+        agent_result = AgentIntegrator().integrate_package_agents(package_info, project_root)
+        skill_result = SkillIntegrator().integrate_package_skill(package_info, project_root)
+        claude_agent_result = AgentIntegrator().integrate_package_agents_claude(package_info, project_root)
+        command_result = CommandIntegrator().integrate_package_commands(package_info, project_root)
+
+        # VS Code / Copilot pickup locations
+        assert prompt_result.files_integrated == 1
+        assert (project_root / ".github" / "prompts" / "test-command.prompt.md").exists()
+
+        assert agent_result.files_integrated == 1
+        assert (project_root / ".github" / "agents" / "test-agent.agent.md").exists()
+
+        assert skill_result.skill_created or skill_result.skill_skipped
+        assert (project_root / ".github" / "skills" / "test-skill" / "SKILL.md").exists()
+
+        # Claude/Copilot-compatible locations produced during install path
+        assert claude_agent_result.files_integrated == 1
+        assert (project_root / ".claude" / "agents" / "test-agent.md").exists()
+
+        assert command_result.files_integrated == 1
+        assert (project_root / ".claude" / "commands" / "test-command.md").exists()

From 57c9a549979f21ba0755663109dcf25f6e140457 Mon Sep 17 00:00:00 2001
From: SebastienDegodez <sebastien.degodez@gmail.com>
Date: Sat, 7 Mar 2026 12:38:30 +0100
Subject: [PATCH 4/8] refactor: remove unused MarketplaceEntry class and its
 methods

---
 src/apm_cli/models/plugin.py | 61 ------------------------------------
 1 file changed, 61 deletions(-)

diff --git a/src/apm_cli/models/plugin.py b/src/apm_cli/models/plugin.py
index f87991daf..72be5ad2c 100644
--- a/src/apm_cli/models/plugin.py
+++ b/src/apm_cli/models/plugin.py
@@ -139,64 +139,3 @@ def from_path(cls, plugin_path: Path) -> "Plugin":
             hooks=hooks,
             skills=skills,
         )
-
-
-@dataclass
-class MarketplaceEntry:
-    """Entry in the plugin marketplace.
-    
-    Attributes:
-        id: Unique plugin identifier
-        name: Human-readable plugin name
-        description: Short description
-        repository: Repository reference (e.g., "owner/repo" or "org/project/repo")
-        version: Latest version string
-        author: Plugin author
-        host: Optional host/platform ("github", "ado", or custom URL like "dev.azure.com")
-        tags: List of tags for categorization
-        downloads: Number of downloads (from marketplace)
-        stars: Number of stars (from marketplace)
-    """
-    id: str
-    name: str
-    description: str
-    repository: str
-    version: str
-    author: str
-    host: Optional[str] = None
-    tags: List[str] = field(default_factory=list)
-    downloads: int = 0
-    stars: int = 0
-
-    @classmethod
-    def from_dict(cls, data: Dict[str, Any]) -> "MarketplaceEntry":
-        """Create a marketplace entry from dictionary."""
-        return cls(
-            id=data["id"],
-            name=data.get("name", data["id"]),
-            description=data.get("description", ""),
-            repository=data["repository"],
-            version=data.get("version", "latest"),
-            author=data.get("author", ""),
-            host=data.get("host"),
-            tags=data.get("tags", []),
-            downloads=data.get("downloads", 0),
-            stars=data.get("stars", 0),
-        )
-
-    def to_dict(self) -> Dict[str, Any]:
-        """Convert to dictionary."""
-        result = {
-            "id": self.id,
-            "name": self.name,
-            "description": self.description,
-            "repository": self.repository,
-            "version": self.version,
-            "author": self.author,
-            "tags": self.tags,
-            "downloads": self.downloads,
-            "stars": self.stars,
-        }
-        if self.host:
-            result["host"] = self.host
-        return result

From 73b38e1ab8fb786703c6621bbb8285d683855a23 Mon Sep 17 00:00:00 2001
From: SebastienDegodez <sebastien.degodez@gmail.com>
Date: Sat, 7 Mar 2026 13:11:35 +0100
Subject: [PATCH 5/8] feat: align plugin support with Claude plugin spec
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- MARKETPLACE_PLUGIN is now the fallback for any directory without
  apm.yml/SKILL.md, per spec: 'The manifest is optional.'
- plugin.json is optional metadata; only name is required (defaults
  to directory name when absent)
- version defaults to short commit SHA when absent from plugin.json
- author accepted as object {name, email, url} or plain string
- .mcp.json, .lsp.json, settings.json, hooks/ copied into .apm/
  so MCP-based plugins function correctly after install
- Added normalize_plugin_directory() — works with or without plugin.json
- Removed scan_plugin_primitives() — plugins are normalized at install
  time so scan_dependency_primitives() handles them naturally
- README.md used as last-resort fallback in validate_virtual_package_exists()
  for plugin directories with no manifest files
- Test fixtures: author updated to object format per spec
- New tests: plugin without plugin.json, .mcp.json passthrough

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 src/apm_cli/deps/github_downloader.py         |  58 +++-
 src/apm_cli/deps/plugin_parser.py             | 247 +++++++++++-------
 src/apm_cli/models/apm_package.py             |  59 ++---
 src/apm_cli/primitives/discovery.py           |  95 +------
 .../.claude-plugin/plugin.json                |   2 +-
 .../.github/plugin/plugin.json                |   2 +-
 .../test_marketplace_plugin_integration.py    |  81 ++++--
 tests/test_apm_package_models.py              |  17 +-
 8 files changed, 302 insertions(+), 259 deletions(-)

diff --git a/src/apm_cli/deps/github_downloader.py b/src/apm_cli/deps/github_downloader.py
index 7f1fe0270..0db08851c 100644
--- a/src/apm_cli/deps/github_downloader.py
+++ b/src/apm_cli/deps/github_downloader.py
@@ -756,7 +756,10 @@ def validate_virtual_package_exists(self, dep_ref: DependencyReference) -> bool:
             except RuntimeError:
                 return False
         
-        # For subdirectory packages, check for apm.yml, SKILL.md, or plugin.json
+        # For subdirectory packages: apm.yml or SKILL.md confirm the type;
+        # plugin.json confirms a Claude plugin; README.md is a last-resort
+        # signal that the directory exists (any directory that follows the
+        # Claude plugin spec may have none of the above).
         if dep_ref.is_virtual_subdirectory():
             # Try apm.yml first
             try:
@@ -772,20 +775,28 @@ def validate_virtual_package_exists(self, dep_ref: DependencyReference) -> bool:
             except RuntimeError:
                 pass
             
-            # Try plugin.json at various marketplace locations
-            # This allows for new plugin formats to be added easily (e.g., .codex, .assistant, etc.)
+            # Try plugin.json at various Claude plugin locations
             plugin_locations = [
                 f"{dep_ref.virtual_path}/plugin.json",                                    # Root
                 f"{dep_ref.virtual_path}/.github/plugin/plugin.json",                     # GitHub Copilot format
                 f"{dep_ref.virtual_path}/.claude-plugin/plugin.json",                     # Claude format
             ]
-            
+
             for plugin_path in plugin_locations:
                 try:
                     self.download_raw_file(dep_ref, plugin_path, ref)
                     return True
                 except RuntimeError:
                     continue
+
+            # Last resort: README.md — any well-formed directory should have one.
+            # A directory that follows the Claude plugin spec (agents/, commands/,
+            # skills/ …) with no manifest files is still a valid plugin.
+            try:
+                self.download_raw_file(dep_ref, f"{dep_ref.virtual_path}/README.md", ref)
+                return True
+            except RuntimeError:
+                pass
         
         # Fallback: try to download the file directly
         try:
@@ -1247,13 +1258,31 @@ def download_subdirectory_package(self, dep_ref: DependencyReference, target_pat
             ref_type=GitReferenceType.BRANCH,
             resolved_commit=resolved_commit
         )
+
+        # For plugins without an explicit version, stamp with the short commit SHA.
+        package = validation_result.package
+        if (
+            validation_result.package_type == PackageType.MARKETPLACE_PLUGIN
+            and package.version == "0.0.0"
+            and resolved_commit != "unknown"
+        ):
+            short_sha = resolved_commit[:7]
+            package.version = short_sha
+            apm_yml_path = target_path / "apm.yml"
+            if apm_yml_path.exists():
+                import yaml as _yaml
+                with open(apm_yml_path, "r", encoding="utf-8") as _f:
+                    _data = _yaml.safe_load(_f) or {}
+                _data["version"] = short_sha
+                with open(apm_yml_path, "w", encoding="utf-8") as _f:
+                    _yaml.dump(_data, _f, default_flow_style=False, sort_keys=False)
         
         # Update progress - complete
         if progress_obj and progress_task_id is not None:
             progress_obj.update(progress_task_id, completed=100, total=100)
         
         return PackageInfo(
-            package=validation_result.package,
+            package=package,
             install_path=target_path,
             resolved_reference=resolved_ref,
             installed_at=datetime.now().isoformat(),
@@ -1392,6 +1421,25 @@ def download_package(
         package = validation_result.package
         package.source = dep_ref.to_github_url()
         package.resolved_commit = resolved_ref.resolved_commit
+
+        # For plugins without an explicit version, use the short commit SHA so the
+        # lock file and conflict detection have a meaningful, stable version string.
+        if (
+            validation_result.package_type == PackageType.MARKETPLACE_PLUGIN
+            and package.version == "0.0.0"
+            and resolved_ref.resolved_commit
+        ):
+            short_sha = resolved_ref.resolved_commit[:7]
+            package.version = short_sha
+            # Keep the synthesized apm.yml in sync
+            apm_yml_path = target_path / "apm.yml"
+            if apm_yml_path.exists():
+                import yaml as _yaml
+                with open(apm_yml_path, "r", encoding="utf-8") as _f:
+                    _data = _yaml.safe_load(_f) or {}
+                _data["version"] = short_sha
+                with open(apm_yml_path, "w", encoding="utf-8") as _f:
+                    _yaml.dump(_data, _f, default_flow_style=False, sort_keys=False)
         
         # Create and return PackageInfo
         return PackageInfo(
diff --git a/src/apm_cli/deps/plugin_parser.py b/src/apm_cli/deps/plugin_parser.py
index feb4f9522..b984469a2 100644
--- a/src/apm_cli/deps/plugin_parser.py
+++ b/src/apm_cli/deps/plugin_parser.py
@@ -1,7 +1,14 @@
-"""Parser for marketplace plugins (plugin.json format).
+"""Parser for Claude plugins (plugin.json format).
 
-This module handles parsing plugin.json files and synthesizing apm.yml
-from marketplace plugin metadata.
+Aligns with the Claude Code plugin spec:
+  https://docs.anthropic.com/en/docs/claude-code/plugins
+
+Key spec rules:
+- The manifest (.claude-plugin/plugin.json) is **optional**.
+- When present, only `name` is required; everything else is optional metadata.
+- When absent, the plugin name is derived from the directory name.
+- Standard component directories: agents/, commands/, skills/, hooks/
+- Pass-through files: .mcp.json, .lsp.json, settings.json
 """
 
 import json
@@ -13,75 +20,112 @@
 
 def parse_plugin_manifest(plugin_json_path: Path) -> Dict[str, Any]:
     """Parse a plugin.json manifest file.
-    
+
     Args:
         plugin_json_path: Path to the plugin.json file
-        
+
     Returns:
         dict: Parsed plugin manifest
-        
+
     Raises:
         FileNotFoundError: If plugin.json does not exist
         ValueError: If plugin.json is invalid JSON
     """
     if not plugin_json_path.exists():
         raise FileNotFoundError(f"plugin.json not found: {plugin_json_path}")
-    
+
     try:
         with open(plugin_json_path, 'r', encoding='utf-8') as f:
             manifest = json.load(f)
     except json.JSONDecodeError as e:
         raise ValueError(f"Invalid JSON in plugin.json: {e}")
-    
+
     return manifest
 
 
+def normalize_plugin_directory(plugin_path: Path, plugin_json_path: Optional[Path] = None) -> Path:
+    """Normalize a Claude plugin directory into an APM package.
+
+    Works with or without plugin.json.  When plugin.json is present it is
+    treated as optional metadata; when absent the plugin name is derived from
+    the directory name.
+
+    Auto-discovers the standard component directories defined by the spec:
+    agents/, commands/, skills/, hooks/, and pass-through files
+    (.mcp.json, .lsp.json, settings.json).
+
+    Args:
+        plugin_path: Root of the plugin directory.
+        plugin_json_path: Optional path to plugin.json (may be None).
+
+    Returns:
+        Path: Path to the generated apm.yml.
+    """
+    manifest: Dict[str, Any] = {}
+
+    if plugin_json_path is not None and plugin_json_path.exists():
+        try:
+            manifest = parse_plugin_manifest(plugin_json_path)
+        except (ValueError, FileNotFoundError):
+            pass  # Treat as empty manifest; fall back to dir-name defaults
+
+    # Derive name from directory if not in manifest
+    if 'name' not in manifest or not manifest['name']:
+        manifest['name'] = plugin_path.name
+
+    return synthesize_apm_yml_from_plugin(plugin_path, manifest)
+
+
 def synthesize_apm_yml_from_plugin(plugin_path: Path, manifest: Dict[str, Any]) -> Path:
-    """Synthesize apm.yml from plugin.json metadata.
-    
-    Maps the plugin's agents/, skills/, commands/ directories into .apm/
-    structure and generates apm.yml with the plugin metadata.
-    
+    """Synthesize apm.yml from plugin metadata.
+
+    Maps the plugin's agents/, skills/, commands/, hooks/ directories and
+    pass-through files (.mcp.json, .lsp.json, settings.json) into .apm/,
+    then generates apm.yml.
+
     Args:
-        plugin_path: Path to the plugin directory
-        manifest: Parsed plugin.json manifest
-        
+        plugin_path: Path to the plugin directory.
+        manifest: Plugin metadata dict (only `name` is required; all other
+                  fields are optional and default gracefully).
+
     Returns:
-        Path: Path to the generated apm.yml
-        
-    Raises:
-        ValueError: If manifest is missing required fields
+        Path: Path to the generated apm.yml.
     """
-    # Validate required fields
-    required_fields = ['name', 'version', 'description']
-    missing = [f for f in required_fields if f not in manifest]
-    if missing:
-        raise ValueError(f"plugin.json missing required fields: {', '.join(missing)}")
-    
+    if not manifest.get('name'):
+        manifest['name'] = plugin_path.name
+
     # Create .apm directory structure
     apm_dir = plugin_path / ".apm"
     apm_dir.mkdir(exist_ok=True)
-    
+
     # Map plugin structure into .apm/ subdirectories
-    _map_plugin_artifacts(plugin_path, apm_dir, manifest)
-    
+    _map_plugin_artifacts(plugin_path, apm_dir)
+
     # Generate apm.yml from plugin metadata
     apm_yml_content = _generate_apm_yml(manifest)
     apm_yml_path = plugin_path / "apm.yml"
-    
+
     with open(apm_yml_path, 'w', encoding='utf-8') as f:
         f.write(apm_yml_content)
-    
+
     return apm_yml_path
 
 
-def _map_plugin_artifacts(plugin_path: Path, apm_dir: Path, manifest: Dict[str, Any]) -> None:
-    """Map plugin artifacts (agents, skills, commands) to .apm/ subdirectories.
-    
+def _map_plugin_artifacts(plugin_path: Path, apm_dir: Path) -> None:
+    """Map plugin artifacts to .apm/ subdirectories and copy pass-through files.
+
+    Copies:
+    - agents/     → .apm/agents/
+    - skills/     → .apm/skills/
+    - commands/   → .apm/prompts/  (*.md normalized to *.prompt.md)
+    - hooks/      → .apm/hooks/
+    - .mcp.json   → .apm/.mcp.json  (MCP-based plugins need this to function)
+    - .lsp.json   → .apm/.lsp.json
+    - settings.json → .apm/settings.json
+
     Args:
-        plugin_path: Path to the plugin directory
-        apm_dir: Path to the .apm/ directory
-        manifest: Plugin manifest (used to understand plugin structure)
+        plugin_path: Root of the plugin directory.
+        apm_dir: Path to the .apm/ directory.
     """
     # Map agents/
     source_agents = plugin_path / "agents"
@@ -90,18 +134,16 @@ def _map_plugin_artifacts(plugin_path: Path, apm_dir: Path, manifest: Dict[str,
         if target_agents.exists():
             shutil.rmtree(target_agents)
         shutil.copytree(source_agents, target_agents)
-    
-    # Map skills/ and convert to .apm/skills/ structure
-    # Each skill subdirectory with SKILL.md goes to .apm/skills/skillname/
+
+    # Map skills/
     source_skills = plugin_path / "skills"
     if source_skills.exists() and source_skills.is_dir():
         target_skills = apm_dir / "skills"
         if target_skills.exists():
             shutil.rmtree(target_skills)
         shutil.copytree(source_skills, target_skills)
-    
-    # Map commands/ to .apm/prompts/
-    # Plugin commands typically become prompts in APM
+
+    # Map commands/ → .apm/prompts/ (normalize .md → .prompt.md)
     source_commands = plugin_path / "commands"
     if source_commands.exists() and source_commands.is_dir():
         target_prompts = apm_dir / "prompts"
@@ -109,87 +151,94 @@ def _map_plugin_artifacts(plugin_path: Path, apm_dir: Path, manifest: Dict[str,
             shutil.rmtree(target_prompts)
         target_prompts.mkdir(parents=True, exist_ok=True)
 
-        # Prompt integration looks for *.prompt.md files. Claude plugin commands
-        # are commonly *.md, so normalize them while preserving subdirectories.
         for source_file in source_commands.rglob("*"):
             if not source_file.is_file():
                 continue
-
             relative_path = source_file.relative_to(source_commands)
             target_path = target_prompts / relative_path
-
             if source_file.name.endswith(".prompt.md"):
                 pass
             elif source_file.suffix == ".md":
                 target_path = target_path.with_name(f"{source_file.stem}.prompt.md")
-
             target_path.parent.mkdir(parents=True, exist_ok=True)
             shutil.copy2(source_file, target_path)
 
+    # Map hooks/
+    source_hooks = plugin_path / "hooks"
+    if source_hooks.exists() and source_hooks.is_dir():
+        target_hooks = apm_dir / "hooks"
+        if target_hooks.exists():
+            shutil.rmtree(target_hooks)
+        shutil.copytree(source_hooks, target_hooks)
+
+    # Pass-through files required for MCP/LSP plugins to function
+    for passthrough in (".mcp.json", ".lsp.json", "settings.json"):
+        source_file = plugin_path / passthrough
+        if source_file.exists():
+            shutil.copy2(source_file, apm_dir / passthrough)
+
 
 def _generate_apm_yml(manifest: Dict[str, Any]) -> str:
-    """Generate apm.yml content from plugin.json metadata.
-    
+    """Generate apm.yml content from plugin metadata.
+
     Args:
-        manifest: Plugin manifest from plugin.json
-        
+        manifest: Plugin metadata dict.
+
     Returns:
-        str: YAML content for apm.yml
+        str: YAML content for apm.yml.
     """
-    apm_package = {
+    apm_package: Dict[str, Any] = {
         'name': manifest.get('name'),
-        'version': manifest.get('version'),
-        'description': manifest.get('description'),
+        'version': manifest.get('version', '0.0.0'),
+        'description': manifest.get('description', ''),
     }
-    
-    # Add optional fields if present
+
+    # author: spec defines it as {name, email, url} object; accept string too
     if 'author' in manifest:
-        apm_package['author'] = manifest['author']
-    
-    if 'license' in manifest:
-        apm_package['license'] = manifest['license']
-    
-    if 'repository' in manifest:
-        apm_package['repository'] = manifest['repository']
-    
-    if 'homepage' in manifest:
-        apm_package['homepage'] = manifest['homepage']
-    
-    if 'tags' in manifest:
-        apm_package['tags'] = manifest['tags']
-    
-    # Add dependencies if present
-    if 'dependencies' in manifest and manifest['dependencies']:
-        apm_package['dependencies'] = {
-            'apm': manifest['dependencies']
-        }
-    
-    # Add type as 'hybrid' to indicate it came from a marketplace plugin
+        author = manifest['author']
+        if isinstance(author, dict):
+            apm_package['author'] = author.get('name', '')
+        else:
+            apm_package['author'] = str(author)
+
+    for field in ('license', 'repository', 'homepage', 'tags'):
+        if field in manifest:
+            apm_package[field] = manifest[field]
+
+    if manifest.get('dependencies'):
+        apm_package['dependencies'] = {'apm': manifest['dependencies']}
+
     apm_package['type'] = 'hybrid'
-    
-    # Generate YAML directly (no apm: wrapper needed)
+
     return yaml.dump(apm_package, default_flow_style=False, sort_keys=False)
 
 
 def validate_plugin_package(plugin_path: Path) -> bool:
-    """Validate that a plugin package has required structure.
-    
+    """Check whether a directory looks like a Claude plugin.
+
+    A directory is a valid plugin if it has plugin.json (with at least a name),
+    or if it contains at least one standard component directory.
+
     Args:
-        plugin_path: Path to the plugin directory
-        
+        plugin_path: Path to the plugin directory.
+
     Returns:
-        bool: True if plugin.json exists and is valid
+        bool: True if the directory appears to be a Claude plugin.
     """
-    plugin_json = plugin_path / "plugin.json"
-    if not plugin_json.exists():
-        return False
-    
-    try:
-        with open(plugin_json, 'r', encoding='utf-8') as f:
-            manifest = json.load(f)
-        
-        # Check for required fields
-        required = ['name', 'version', 'description']
-        return all(field in manifest for field in required)
-    except (json.JSONDecodeError, IOError):
-        return False
+    # Check for plugin.json (optional; only name is required when present)
+    from ..utils.helpers import find_plugin_json
+    plugin_json = find_plugin_json(plugin_path)
+    if plugin_json is not None:
+        try:
+            with open(plugin_json, 'r', encoding='utf-8') as f:
+                manifest = json.load(f)
+            return bool(manifest.get('name'))
+        except (json.JSONDecodeError, IOError):
+            pass
+
+    # Fallback: presence of any standard component directory
+    for component_dir in ("agents", "commands", "skills", "hooks"):
+        if (plugin_path / component_dir).is_dir():
+            return True
+
+    return False
diff --git a/src/apm_cli/models/apm_package.py b/src/apm_cli/models/apm_package.py
index 5e10168e5..b0bd11c67 100644
--- a/src/apm_cli/models/apm_package.py
+++ b/src/apm_cli/models/apm_package.py
@@ -1333,17 +1333,17 @@ def validate_apm_package(package_path: Path) -> ValidationResult:
     # Detect package type
     apm_yml_path = package_path / "apm.yml"
     skill_md_path = package_path / "SKILL.md"
-    
-    # Check for plugin.json using centralized helper
+
+    # Check for plugin.json — optional metadata, not a detection gate
     from ..utils.helpers import find_plugin_json
     plugin_json_path = find_plugin_json(package_path)
-    
+
     has_apm_yml = apm_yml_path.exists()
     has_skill_md = skill_md_path.exists()
     has_hooks = _has_hook_json(package_path)
-    has_plugin_json = plugin_json_path is not None
-    
-    # Determine package type (apm.yml takes precedence)
+
+    # Determine package type.  apm.yml / SKILL.md take precedence; everything
+    # else (hooks-only or bare plugin directories) normalizes as a Claude plugin.
     if has_apm_yml and has_skill_md:
         result.package_type = PackageType.HYBRID
     elif has_apm_yml:
@@ -1352,12 +1352,10 @@ def validate_apm_package(package_path: Path) -> ValidationResult:
         result.package_type = PackageType.CLAUDE_SKILL
     elif has_hooks:
         result.package_type = PackageType.HOOK_PACKAGE
-    elif has_plugin_json:
-        result.package_type = PackageType.MARKETPLACE_PLUGIN
     else:
-        result.package_type = PackageType.INVALID
-        result.add_error("Missing required file: apm.yml, SKILL.md, plugin.json, or hooks/*.json")
-        return result
+        # Fallback: treat any directory without apm.yml / SKILL.md as a Claude plugin.
+        # plugin.json, when present, is read as optional metadata.
+        result.package_type = PackageType.MARKETPLACE_PLUGIN
     
     # Handle hook-only packages (no apm.yml or SKILL.md)
     if result.package_type == PackageType.HOOK_PACKAGE:
@@ -1443,39 +1441,36 @@ def _validate_claude_skill(package_path: Path, skill_md_path: Path, result: Vali
     return result
 
 
-def _validate_marketplace_plugin(package_path: Path, plugin_json_path: Path, result: ValidationResult) -> ValidationResult:
-    """Validate a marketplace plugin and synthesize apm.yml from plugin.json.
-    
+def _validate_marketplace_plugin(package_path: Path, plugin_json_path: Optional[Path], result: ValidationResult) -> ValidationResult:
+    """Validate a Claude plugin and synthesize apm.yml.
+
+    plugin.json is **optional** per the spec.  When present it provides
+    metadata (name, version, description …).  When absent the plugin name is
+    derived from the directory name and all other fields default gracefully.
+
     Args:
         package_path: Path to the package directory
-        plugin_json_path: Path to plugin.json
+        plugin_json_path: Path to plugin.json if found, or None
         result: ValidationResult to populate
-        
+
     Returns:
         ValidationResult: Updated validation result with MARKETPLACE_PLUGIN type
     """
-    from ..deps.plugin_parser import parse_plugin_manifest, synthesize_apm_yml_from_plugin
-    
+    from ..deps.plugin_parser import normalize_plugin_directory
+
     try:
-        # Parse plugin.json manifest
-        manifest = parse_plugin_manifest(plugin_json_path)
-        
-        # Synthesize apm.yml from plugin metadata
-        apm_yml_path = synthesize_apm_yml_from_plugin(package_path, manifest)
-        
-        # Now validate the synthesized apm.yml
+        # Normalize the plugin directory; plugin.json is optional metadata
+        apm_yml_path = normalize_plugin_directory(package_path, plugin_json_path)
+
+        # Load the synthesized apm.yml
         package = APMPackage.from_apm_yml(apm_yml_path)
         result.package = package
-        # Keep the MARKETPLACE_PLUGIN type to track that it came from plugin.json
         result.package_type = PackageType.MARKETPLACE_PLUGIN
-        
-    except (ValueError, FileNotFoundError) as e:
-        result.add_error(f"Invalid plugin.json: {e}")
-        return result
+
     except Exception as e:
-        result.add_error(f"Failed to process marketplace plugin: {e}")
+        result.add_error(f"Failed to process Claude plugin: {e}")
         return result
-    
+
     return result
 
 
diff --git a/src/apm_cli/primitives/discovery.py b/src/apm_cli/primitives/discovery.py
index 618d8adf6..bc9d97bef 100644
--- a/src/apm_cli/primitives/discovery.py
+++ b/src/apm_cli/primitives/discovery.py
@@ -98,19 +98,18 @@ def discover_primitives_with_dependencies(base_dir: str = ".") -> PrimitiveColle
         PrimitiveCollection: Collection of discovered and parsed primitives with source tracking.
     """
     collection = PrimitiveCollection()
-    
+
     # Phase 1: Local primitives (highest priority)
     scan_local_primitives(base_dir, collection)
-    
+
     # Phase 1b: Local SKILL.md
     _discover_local_skill(base_dir, collection)
-    
+
     # Phase 2: Dependency primitives (lower priority, with conflict detection)
+    # Plugins are normalized into standard APM packages during install
+    # (apm.yml + .apm/ are synthesized), so scan_dependency_primitives handles them.
     scan_dependency_primitives(base_dir, collection)
-    
-    # Phase 3: Plugin primitives (lowest priority)
-    scan_plugin_primitives(base_dir, collection)
-    
+
     return collection
 
 
@@ -303,88 +302,6 @@ def scan_directory_with_source(directory: Path, collection: PrimitiveCollection,
     _discover_skill_in_directory(directory, collection, source)
 
 
-def scan_plugin_primitives(base_dir: str, collection: PrimitiveCollection) -> None:
-    """Scan apm_modules/ directory for plugins with primitives (agents, skills, instructions).
-    
-    This function discovers primitives from installed plugins in the apm_modules/ directory.
-    Plugins are identified by the presence of a plugin.json file. Plugins have the lowest 
-    priority in the primitive resolution order.
-    
-    Plugin structure: apm_modules/{owner}/{repo}/plugins/
-    
-    Args:
-        base_dir (str): Base directory to search in.
-        collection (PrimitiveCollection): Collection to add primitives to.
-    """
-    apm_modules_path = Path(base_dir) / "apm_modules"
-    if not apm_modules_path.exists():
-        return
-    
-    # Iterate through all directories in apm_modules/{owner}/{repo}/ structure
-    for owner_dir in apm_modules_path.iterdir():
-        if not owner_dir.is_dir():
-            continue
-        
-        for repo_dir in owner_dir.iterdir():
-            if not repo_dir.is_dir():
-                continue
-            
-            # Check if this is a plugin (has plugin.json in any supported location)
-            from ..utils.helpers import find_plugin_json
-            active_plugin_json = find_plugin_json(repo_dir)
-            
-            if active_plugin_json is None:
-                continue
-            
-            # Get plugin ID from plugin.json
-            try:
-                import json
-                with open(active_plugin_json) as f:
-                    plugin_data = json.load(f)
-                    plugin_id = plugin_data.get("id", repo_dir.name)
-            except (json.JSONDecodeError, OSError):
-                plugin_id = repo_dir.name
-            
-            source = f"plugin:{plugin_id}"
-            
-            # Plugin primitives are always at the repository root
-            # (not in the folder containing plugin.json)
-            base_dir = repo_dir
-            
-            # Scan agents/ (including subdirectories)
-            agents_dir = base_dir / "agents"
-            if agents_dir.exists():
-                for agent_file in agents_dir.rglob("*.agent.md"):
-                    if _is_readable(agent_file):
-                        try:
-                            primitive = parse_primitive_file(agent_file, source=source)
-                            collection.add_primitive(primitive)
-                        except Exception as e:
-                            print(f"Warning: Failed to parse plugin agent {agent_file}: {e}")
-            
-            # Scan skills/ subdirectories
-            # Each subdirectory in skills/ must contain a SKILL.md
-            skills_dir = base_dir / "skills"
-            if skills_dir.exists():
-                for skill_subdir in skills_dir.iterdir():
-                    if skill_subdir.is_dir():
-                        _discover_skill_in_directory(skill_subdir, collection, source)
-            
-            # Scan instructions/ (including subdirectories)
-            instructions_dir = base_dir / "instructions"
-            if instructions_dir.exists():
-                for inst_file in instructions_dir.rglob("*.instructions.md"):
-                    if _is_readable(inst_file):
-                        try:
-                            primitive = parse_primitive_file(inst_file, source=source)
-                            collection.add_primitive(primitive)
-                        except Exception as e:
-                            print(f"Warning: Failed to parse plugin instruction {inst_file}: {e}")
-            
-            # Also check for SKILL.md in the root directory
-            _discover_skill_in_directory(base_dir, collection, source)
-
-
 def _discover_local_skill(base_dir: str, collection: PrimitiveCollection) -> None:
     """Discover SKILL.md at the project root.
     
diff --git a/tests/fixtures/mock-claude-plugin/.claude-plugin/plugin.json b/tests/fixtures/mock-claude-plugin/.claude-plugin/plugin.json
index 6f8855d48..251e3a764 100644
--- a/tests/fixtures/mock-claude-plugin/.claude-plugin/plugin.json
+++ b/tests/fixtures/mock-claude-plugin/.claude-plugin/plugin.json
@@ -2,7 +2,7 @@
   "name": "Mock Marketplace Plugin",
   "version": "1.0.0",
   "description": "A test marketplace plugin for APM integration testing",
-  "author": "APM Test Suite",
+  "author": {"name": "APM Test Suite"},
   "license": "MIT",
   "repository": "microsoft/apm-test-plugin",
   "homepage": "https://github.com/microsoft/apm",
diff --git a/tests/fixtures/mock-marketplace-plugin/.github/plugin/plugin.json b/tests/fixtures/mock-marketplace-plugin/.github/plugin/plugin.json
index 6f8855d48..251e3a764 100644
--- a/tests/fixtures/mock-marketplace-plugin/.github/plugin/plugin.json
+++ b/tests/fixtures/mock-marketplace-plugin/.github/plugin/plugin.json
@@ -2,7 +2,7 @@
   "name": "Mock Marketplace Plugin",
   "version": "1.0.0",
   "description": "A test marketplace plugin for APM integration testing",
-  "author": "APM Test Suite",
+  "author": {"name": "APM Test Suite"},
   "license": "MIT",
   "repository": "microsoft/apm-test-plugin",
   "homepage": "https://github.com/microsoft/apm",
diff --git a/tests/integration/test_marketplace_plugin_integration.py b/tests/integration/test_marketplace_plugin_integration.py
index 48b5d0224..43ee99550 100644
--- a/tests/integration/test_marketplace_plugin_integration.py
+++ b/tests/integration/test_marketplace_plugin_integration.py
@@ -34,17 +34,16 @@ def test_plugin_detection_and_synthesis(self, tmp_path):
         """Test that plugin.json is detected and apm.yml is synthesized (root location)."""
         plugin_dir = tmp_path / "test-plugin"
         plugin_dir.mkdir()
-        
-        # Create plugin.json
+
+        # Create plugin.json (version is optional per spec)
         plugin_json = {
             "name": "Test Plugin",
-            "version": "1.0.0",
             "description": "A test plugin",
-            "author": "Test Author",
+            "author": {"name": "Test Author"},
             "license": "MIT",
             "tags": ["testing"]
         }
-        
+
         with open(plugin_dir / "plugin.json", "w") as f:
             json.dump(plugin_json, f)
         
@@ -59,7 +58,7 @@ def test_plugin_detection_and_synthesis(self, tmp_path):
         assert result.package_type == PackageType.MARKETPLACE_PLUGIN
         assert result.package is not None
         assert result.package.name == "Test Plugin"
-        assert result.package.version == "1.0.0"
+        assert result.package.version == "0.0.0"  # defaults when absent
         
         # Verify synthesized apm.yml exists
         apm_yml_path = plugin_dir / "apm.yml"
@@ -243,7 +242,7 @@ def test_plugin_with_dependencies(self, tmp_path):
   "name": "Plugin With Dependencies",
   "version": "2.0.0",
   "description": "A plugin with dependencies",
-  "author": "Test Author",
+  "author": {"name": "Test Author"},
   "dependencies": [
     "owner/dependency-package",
     "another/required-package#v1.0"
@@ -279,7 +278,7 @@ def test_plugin_metadata_preservation(self, tmp_path):
   "name": "Full Metadata Plugin",
   "version": "1.5.0",
   "description": "A plugin with complete metadata",
-  "author": "APM Contributors",
+  "author": {"name": "APM Contributors", "email": "apm@microsoft.com"},
   "license": "Apache-2.0",
   "repository": "microsoft/apm-plugin",
   "homepage": "https://apm.dev/plugins/test",
@@ -297,7 +296,7 @@ def test_plugin_metadata_preservation(self, tmp_path):
         assert package.name == "Full Metadata Plugin"
         assert package.version == "1.5.0"
         assert package.description == "A plugin with complete metadata"
-        assert package.author == "APM Contributors"
+        assert package.author == "APM Contributors"  # extracted from author.name
         assert package.license == "Apache-2.0"
         
         # Read apm.yml and verify fields
@@ -309,26 +308,20 @@ def test_plugin_metadata_preservation(self, tmp_path):
         assert "agents" in apm_yml
     
     def test_invalid_plugin_json(self, tmp_path):
-        """Test that invalid plugin.json is handled gracefully."""
+        """Test that malformed plugin.json (invalid JSON syntax) is handled gracefully."""
         plugin_dir = tmp_path / "invalid-plugin"
         plugin_dir.mkdir()
-        
-        # Create invalid plugin.json (missing required fields)
+
+        # Write syntactically invalid JSON
         plugin_json = plugin_dir / "plugin.json"
-        plugin_json.write_text("""
-{
-  "name": "Invalid Plugin"
-}
-""")
-        
-        # Validate
+        plugin_json.write_text("{ this is not valid json }")
+
+        # Validate — the parser should fall back to dir-name defaults and succeed
         result = validate_apm_package(plugin_dir)
-        
-        # Should fail validation
-        assert not result.is_valid
-        assert len(result.errors) > 0
-        assert any("version" in err.lower() or "required" in err.lower() 
-                   for err in result.errors)
+        assert result.package_type == PackageType.MARKETPLACE_PLUGIN
+        # name derived from directory name
+        assert result.package is not None
+        assert result.package.name == "invalid-plugin"
     
     def test_plugin_without_artifacts(self, tmp_path):
         """Test plugin with only plugin.json and no artifacts."""
@@ -356,7 +349,43 @@ def test_plugin_without_artifacts(self, tmp_path):
         apm_dir = plugin_dir / ".apm"
         assert apm_dir.exists()
 
-    def test_plugin_install_is_immediately_pickup_ready_for_vscode_and_copilot(self, tmp_path):
+    def test_plugin_without_plugin_json(self, tmp_path):
+        """Any directory with standard component dirs and no apm.yml/SKILL.md is a Claude plugin."""
+        plugin_dir = tmp_path / "no-manifest-plugin"
+        plugin_dir.mkdir()
+
+        # Only standard component directories — no plugin.json at all
+        (plugin_dir / "commands").mkdir()
+        (plugin_dir / "commands" / "do-something.md").write_text("# Do Something")
+        (plugin_dir / "agents").mkdir()
+        (plugin_dir / "agents" / "helper.agent.md").write_text("# Helper")
+
+        result = validate_apm_package(plugin_dir)
+
+        assert result.package_type == PackageType.MARKETPLACE_PLUGIN
+        assert result.is_valid
+        assert result.package is not None
+        # Name derived from directory name
+        assert result.package.name == "no-manifest-plugin"
+        assert result.package.version == "0.0.0"
+
+    def test_mcp_json_copied_through(self, tmp_path):
+        """MCP plugins: .mcp.json must be present in .apm/ after normalization."""
+        plugin_dir = tmp_path / "mcp-plugin"
+        plugin_dir.mkdir()
+
+        mcp_config = {"mcpServers": {"my-server": {"command": "node", "args": ["index.js"]}}}
+        (plugin_dir / ".mcp.json").write_text(json.dumps(mcp_config))
+        (plugin_dir / "commands").mkdir()
+        (plugin_dir / "commands" / "run.md").write_text("# Run")
+
+        result = validate_apm_package(plugin_dir)
+
+        assert result.package_type == PackageType.MARKETPLACE_PLUGIN
+        assert result.is_valid
+        assert (plugin_dir / ".apm" / ".mcp.json").exists(), ".mcp.json must be copied to .apm/"
+
+
         """Plugin install should populate .github/.claude targets consumed by editors."""
         fixture_path = Path(__file__).parent.parent / "fixtures" / "mock-marketplace-plugin"
         plugin_dir = tmp_path / "installed-plugin"
diff --git a/tests/test_apm_package_models.py b/tests/test_apm_package_models.py
index b7a189576..c30734918 100644
--- a/tests/test_apm_package_models.py
+++ b/tests/test_apm_package_models.py
@@ -661,11 +661,14 @@ def test_validate_file_instead_of_directory(self):
             assert any("not a directory" in error for error in result.errors)
     
     def test_validate_missing_apm_yml(self):
-        """Test validating directory without apm.yml, SKILL.md, or hooks."""
+        """Test that a directory without apm.yml/SKILL.md normalizes as a Claude plugin."""
         with tempfile.TemporaryDirectory() as tmpdir:
             result = validate_apm_package(Path(tmpdir))
-            assert not result.is_valid
-            assert any("Missing required file" in error for error in result.errors)
+            # Per the Claude plugin spec any directory is a valid (empty) plugin;
+            # the name is derived from the directory name.
+            assert result.is_valid
+            assert result.package_type == PackageType.MARKETPLACE_PLUGIN
+            assert result.package is not None
     
     def test_validate_invalid_apm_yml(self):
         """Test validating directory with invalid apm.yml."""
@@ -935,11 +938,13 @@ def test_validate_hook_package_prefers_apm_yml(self):
             assert result.package_type == PackageType.APM_PACKAGE
 
     def test_validate_empty_dir_is_invalid(self):
-        """Test that a dir with no apm.yml, SKILL.md, or hooks is invalid."""
+        """Test that a dir with no apm.yml, SKILL.md, or hooks normalizes as Claude plugin."""
         with tempfile.TemporaryDirectory() as tmpdir:
             result = validate_apm_package(Path(tmpdir))
-            assert not result.is_valid
-            assert result.package_type == PackageType.INVALID
+            # Per the Claude plugin spec any directory is treated as a valid plugin;
+            # INVALID is no longer returned as a fallback.
+            assert result.is_valid
+            assert result.package_type == PackageType.MARKETPLACE_PLUGIN
 
 
 class TestGitReferenceUtils:

From 2cd83290c13bcba180e677ad21f90b9ddd36c590 Mon Sep 17 00:00:00 2001
From: SebastienDegodez <sebastien.degodez@gmail.com>
Date: Mon, 9 Mar 2026 14:32:53 +0100
Subject: [PATCH 6/8] refactor: update plugin.json location checks to a
 deterministic three-location priority

---
 docs/plugins.md                               |  7 +-
 src/apm_cli/models/plugin.py                  |  2 +-
 src/apm_cli/utils/helpers.py                  | 22 ++++---
 .../test_marketplace_plugin_integration.py    |  8 +--
 tests/unit/test_helpers.py                    | 64 ++++++++++++++++++-
 5 files changed, 85 insertions(+), 18 deletions(-)

diff --git a/docs/plugins.md b/docs/plugins.md
index 8da18b573..ed014a0fb 100644
--- a/docs/plugins.md
+++ b/docs/plugins.md
@@ -118,9 +118,10 @@ plugin-repo/
     └── command-2.md
 ```
 
-**Priority Order**: APM searches for `plugin.json`:
-1. `plugin.json` (root) - checked first
-2. Then recursively in subdirectories (e.g., `.github/plugin/`, `.claude-plugin/`)
+**Priority Order**: APM checks for `plugin.json` in exactly three locations:
+1. `plugin.json` (root)
+2. `.github/plugin/plugin.json`
+3. `.claude-plugin/plugin.json`
 
 **Note**: Primitives (agents, skills, commands, instructions) are always located at the repository root, regardless of where `plugin.json` is located.
 
diff --git a/src/apm_cli/models/plugin.py b/src/apm_cli/models/plugin.py
index 72be5ad2c..a7a008e03 100644
--- a/src/apm_cli/models/plugin.py
+++ b/src/apm_cli/models/plugin.py
@@ -88,7 +88,7 @@ class Plugin:
     def from_path(cls, plugin_path: Path) -> "Plugin":
         """Load a plugin from its installation directory.
         
-        Plugin structure: plugin.json can be in .github/plugin/, .claude-plugin/, plugins/, or root.
+        Plugin structure: plugin.json can be in root, .github/plugin/, or .claude-plugin/.
         Primitives (agents, skills, etc.) are always at the repository root.
         
         Args:
diff --git a/src/apm_cli/utils/helpers.py b/src/apm_cli/utils/helpers.py
index 90135804c..99c91452d 100644
--- a/src/apm_cli/utils/helpers.py
+++ b/src/apm_cli/utils/helpers.py
@@ -106,7 +106,10 @@ def detect_platform():
 def find_plugin_json(plugin_path: Path) -> Optional[Path]:
     """Find plugin.json in a plugin directory.
     
-    Searches for plugin.json first at the root, then recursively in subdirectories.
+    Checks exactly three spec-defined locations in priority order:
+      1. <root>/plugin.json
+      2. <root>/.github/plugin/plugin.json
+      3. <root>/.claude-plugin/plugin.json
     
     Args:
         plugin_path: Path to the plugin directory
@@ -114,11 +117,12 @@ def find_plugin_json(plugin_path: Path) -> Optional[Path]:
     Returns:
         Optional[Path]: Path to the plugin.json file if found, None otherwise
     """
-    # Check root first (fastest)
-    root_plugin = plugin_path / "plugin.json"
-    if root_plugin.exists():
-        return root_plugin
-    
-    # Fallback to recursive search in subdirectories
-    matches = list(plugin_path.glob("**/plugin.json"))
-    return matches[0] if matches else None
+    candidates = [
+        plugin_path / "plugin.json",
+        plugin_path / ".github" / "plugin" / "plugin.json",
+        plugin_path / ".claude-plugin" / "plugin.json",
+    ]
+    for candidate in candidates:
+        if candidate.exists():
+            return candidate
+    return None
diff --git a/tests/integration/test_marketplace_plugin_integration.py b/tests/integration/test_marketplace_plugin_integration.py
index 43ee99550..aea2eff5d 100644
--- a/tests/integration/test_marketplace_plugin_integration.py
+++ b/tests/integration/test_marketplace_plugin_integration.py
@@ -133,8 +133,8 @@ def test_claude_plugin_format(self, tmp_path):
         assert result.package.version == "3.0.0"
     
     def test_plugin_location_priority(self, tmp_path):
-        """Test that plugin.json is found at root first, then recursively in subdirectories."""
-        # Test 1: Root plugin.json takes priority over subdirectories
+        """Test that plugin.json is found via deterministic 3-location check."""
+        # Test 1: Root plugin.json takes priority
         plugin_dir = tmp_path / "priority-test"
         plugin_dir.mkdir()
         
@@ -158,7 +158,7 @@ def test_plugin_location_priority(self, tmp_path):
         assert result.package.name == "Root Plugin"
         assert result.package.version == "1.0.0"
         
-        # Test 2: Recursive search finds .github/plugin/ when no root
+        # Test 2: .github/plugin/ is found when no root plugin.json
         plugin_dir2 = tmp_path / "github-test"
         plugin_dir2.mkdir()
         (plugin_dir2 / ".github" / "plugin").mkdir(parents=True)
@@ -170,7 +170,7 @@ def test_plugin_location_priority(self, tmp_path):
         assert result2.package.name == "GitHub Plugin"
         assert result2.package.version == "2.0.0"
         
-        # Test 3: Recursive search finds .claude-plugin/ when no root
+        # Test 3: .claude-plugin/ is found when no root plugin.json
         plugin_dir3 = tmp_path / "claude-test"
         plugin_dir3.mkdir()
         (plugin_dir3 / ".claude-plugin").mkdir()
diff --git a/tests/unit/test_helpers.py b/tests/unit/test_helpers.py
index 173ba886c..3efb5f129 100644
--- a/tests/unit/test_helpers.py
+++ b/tests/unit/test_helpers.py
@@ -1,8 +1,10 @@
 """Tests for helper utility functions."""
 
+import json
 import unittest
 import sys
-from apm_cli.utils.helpers import is_tool_available, detect_platform, get_available_package_managers
+from pathlib import Path
+from apm_cli.utils.helpers import is_tool_available, detect_platform, get_available_package_managers, find_plugin_json
 
 
 class TestHelpers(unittest.TestCase):
@@ -53,5 +55,65 @@ def test_get_available_package_managers(self):
                              "Expected at least one package manager on Unix systems")
 
 
+class TestFindPluginJson(unittest.TestCase):
+    """Test cases for find_plugin_json deterministic location check."""
+
+    def test_finds_root_plugin_json(self, tmp_path=None):
+        """Root plugin.json is returned when present."""
+        import tempfile
+        with tempfile.TemporaryDirectory() as d:
+            root = Path(d)
+            pj = root / "plugin.json"
+            pj.write_text(json.dumps({"name": "test"}))
+            assert find_plugin_json(root) == pj
+
+    def test_finds_github_plugin_json(self):
+        """plugin.json under .github/plugin/ is found."""
+        import tempfile
+        with tempfile.TemporaryDirectory() as d:
+            root = Path(d)
+            target = root / ".github" / "plugin" / "plugin.json"
+            target.parent.mkdir(parents=True)
+            target.write_text(json.dumps({"name": "gh"}))
+            assert find_plugin_json(root) == target
+
+    def test_finds_claude_plugin_json(self):
+        """plugin.json under .claude-plugin/ is found."""
+        import tempfile
+        with tempfile.TemporaryDirectory() as d:
+            root = Path(d)
+            target = root / ".claude-plugin" / "plugin.json"
+            target.parent.mkdir(parents=True)
+            target.write_text(json.dumps({"name": "claude"}))
+            assert find_plugin_json(root) == target
+
+    def test_priority_order(self):
+        """Root wins over .github/plugin/ which wins over .claude-plugin/."""
+        import tempfile
+        with tempfile.TemporaryDirectory() as d:
+            root = Path(d)
+            for sub in ["plugin.json", ".github/plugin/plugin.json", ".claude-plugin/plugin.json"]:
+                p = root / sub
+                p.parent.mkdir(parents=True, exist_ok=True)
+                p.write_text(json.dumps({"name": sub}))
+            assert find_plugin_json(root) == root / "plugin.json"
+
+    def test_ignores_unrelated_locations(self):
+        """plugin.json buried in node_modules or other dirs is NOT found."""
+        import tempfile
+        with tempfile.TemporaryDirectory() as d:
+            root = Path(d)
+            hidden = root / "node_modules" / "evil" / "plugin.json"
+            hidden.parent.mkdir(parents=True)
+            hidden.write_text(json.dumps({"name": "evil"}))
+            assert find_plugin_json(root) is None
+
+    def test_returns_none_when_absent(self):
+        """None is returned when no plugin.json exists anywhere."""
+        import tempfile
+        with tempfile.TemporaryDirectory() as d:
+            assert find_plugin_json(Path(d)) is None
+
+
 if __name__ == '__main__':
     unittest.main()
\ No newline at end of file

From 5659ff92d0b0ed0d1148d65fe7dad3883ab897a2 Mon Sep 17 00:00:00 2001
From: SebastienDegodez <sebastien.degodez@gmail.com>
Date: Mon, 9 Mar 2026 14:35:52 +0100
Subject: [PATCH 7/8] fix: prevent symlink traversal in plugin artifact mapping

Add symlinks=False to all shutil.copytree() calls and skip symlinks
in file iteration loops within _map_plugin_artifacts(). This prevents
a malicious plugin from using symlinks to exfiltrate sensitive files
(e.g. /etc/passwd, ~/.ssh/) into the project's .apm/ directory.
---
 src/apm_cli/deps/plugin_parser.py | 10 +++++-----
 1 file changed, 5 insertions(+), 5 deletions(-)

diff --git a/src/apm_cli/deps/plugin_parser.py b/src/apm_cli/deps/plugin_parser.py
index b984469a2..91fd6d1b4 100644
--- a/src/apm_cli/deps/plugin_parser.py
+++ b/src/apm_cli/deps/plugin_parser.py
@@ -133,7 +133,7 @@ def _map_plugin_artifacts(plugin_path: Path, apm_dir: Path) -> None:
         target_agents = apm_dir / "agents"
         if target_agents.exists():
             shutil.rmtree(target_agents)
-        shutil.copytree(source_agents, target_agents)
+        shutil.copytree(source_agents, target_agents, symlinks=False)
 
     # Map skills/
     source_skills = plugin_path / "skills"
@@ -141,7 +141,7 @@ def _map_plugin_artifacts(plugin_path: Path, apm_dir: Path) -> None:
         target_skills = apm_dir / "skills"
         if target_skills.exists():
             shutil.rmtree(target_skills)
-        shutil.copytree(source_skills, target_skills)
+        shutil.copytree(source_skills, target_skills, symlinks=False)
 
     # Map commands/ → .apm/prompts/ (normalize .md → .prompt.md)
     source_commands = plugin_path / "commands"
@@ -152,7 +152,7 @@ def _map_plugin_artifacts(plugin_path: Path, apm_dir: Path) -> None:
         target_prompts.mkdir(parents=True, exist_ok=True)
 
         for source_file in source_commands.rglob("*"):
-            if not source_file.is_file():
+            if not source_file.is_file() or source_file.is_symlink():
                 continue
             relative_path = source_file.relative_to(source_commands)
             target_path = target_prompts / relative_path
@@ -169,12 +169,12 @@ def _map_plugin_artifacts(plugin_path: Path, apm_dir: Path) -> None:
         target_hooks = apm_dir / "hooks"
         if target_hooks.exists():
             shutil.rmtree(target_hooks)
-        shutil.copytree(source_hooks, target_hooks)
+        shutil.copytree(source_hooks, target_hooks, symlinks=False)
 
     # Pass-through files required for MCP/LSP plugins to function
     for passthrough in (".mcp.json", ".lsp.json", "settings.json"):
         source_file = plugin_path / passthrough
-        if source_file.exists():
+        if source_file.exists() and not source_file.is_symlink():
             shutil.copy2(source_file, apm_dir / passthrough)
 
 

From 07254ce1ead899a54ab3395675b86cfd30f86dfb Mon Sep 17 00:00:00 2001
From: SebastienDegodez <sebastien.degodez@gmail.com>
Date: Mon, 9 Mar 2026 14:47:41 +0100
Subject: [PATCH 8/8] fix: skip plugin skill sub-dirs in orphan detection

The rglob scan in list_packages() treated skills/*/SKILL.md directories
inside plugins as independent orphaned packages. Added
_is_nested_under_package() helper that walks up to apm_modules/ and
skips candidates whose parent already contains apm.yml.

Fixes false orphan warnings in deps list and deps tree.
---
 src/apm_cli/commands/deps.py                  | 31 ++++++++++-
 .../test_virtual_package_orphan_detection.py  | 54 +++++++++++++++++++
 2 files changed, 84 insertions(+), 1 deletion(-)

diff --git a/src/apm_cli/commands/deps.py b/src/apm_cli/commands/deps.py
index a2c32dbba..305a99ae0 100644
--- a/src/apm_cli/commands/deps.py
+++ b/src/apm_cli/commands/deps.py
@@ -130,7 +130,13 @@ def list_packages():
             # Skip sub-skills inside .apm/ directories — they belong to the parent package
             if '.apm' in rel_parts:
                 continue
-            
+
+            # 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
+
             try:
                 version = 'unknown'
                 if has_apm_yml:
@@ -357,6 +363,10 @@ def _add_children(parent_branch, parent_repo_url, depth=0):
                         rel_parts = candidate.relative_to(apm_modules_path).parts
                         if len(rel_parts) < 2:
                             continue
+                        if '.apm' in rel_parts:
+                            continue
+                        if has_skill and not has_apm and _is_nested_under_package(candidate, apm_modules_path):
+                            continue
                         display = "/".join(rel_parts)
                         info = _get_package_display_info(candidate)
                         branch = root_tree.add(f"[green]{info['display_name']}[/green]")
@@ -570,6 +580,25 @@ def info(package: str):
 
 # 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.
     
diff --git a/tests/integration/test_virtual_package_orphan_detection.py b/tests/integration/test_virtual_package_orphan_detection.py
index 3084751a0..59d70a4bf 100644
--- a/tests/integration/test_virtual_package_orphan_detection.py
+++ b/tests/integration/test_virtual_package_orphan_detection.py
@@ -531,3 +531,57 @@ def test_get_dependency_declaration_order_virtual_subdirectory(tmp_path):
 
     assert len(dep_order) == 1
     assert dep_order[0] == "owner/repo/skills/azure-naming"
+
+
+# ---------------------------------------------------------------------------
+# Tests for _is_nested_under_package — plugin skill sub-dirs must not be
+# treated as orphaned packages.
+# ---------------------------------------------------------------------------
+
+@pytest.mark.integration
+def test_plugin_skill_subdirs_not_flagged_as_orphans(tmp_path):
+    """Skill sub-directories inside a plugin must not appear as orphaned packages.
+
+    Scenario: a plugin installed at ``apm_modules/owner/my-plugin/`` contains
+    ``skills/skill-a/SKILL.md`` and ``skills/skill-b/SKILL.md``.  These are
+    deployment artifacts inside the parent package, not independent packages.
+    """
+    from apm_cli.commands.deps import _is_nested_under_package
+
+    apm_modules = tmp_path / "apm_modules"
+    plugin_root = apm_modules / "owner" / "my-plugin"
+    plugin_root.mkdir(parents=True)
+    (plugin_root / "apm.yml").write_text("name: my-plugin\nversion: 1.0.0\n")
+
+    # Simulate skills shipped with the plugin (outside .apm/)
+    for skill_name in ("skill-a", "skill-b", "skill-c"):
+        skill_dir = plugin_root / "skills" / skill_name
+        skill_dir.mkdir(parents=True)
+        (skill_dir / "SKILL.md").write_text(f"# {skill_name}\n")
+
+    # Each skill sub-dir must be detected as nested
+    for skill_name in ("skill-a", "skill-b", "skill-c"):
+        nested = _is_nested_under_package(
+            plugin_root / "skills" / skill_name, apm_modules
+        )
+        assert nested, (
+            f"skills/{skill_name} should be detected as nested under my-plugin"
+        )
+
+    # The plugin root itself must NOT be detected as nested
+    assert not _is_nested_under_package(plugin_root, apm_modules)
+
+
+@pytest.mark.integration
+def test_standalone_skill_package_not_skipped(tmp_path):
+    """A standalone skill that has only SKILL.md (no parent apm.yml) must NOT
+    be skipped by the nested-under-package check."""
+    from apm_cli.commands.deps import _is_nested_under_package
+
+    apm_modules = tmp_path / "apm_modules"
+    skill_root = apm_modules / "owner" / "my-skill"
+    skill_root.mkdir(parents=True)
+    (skill_root / "SKILL.md").write_text("# My Skill\n")
+    # No apm.yml in any ancestor between skill_root and apm_modules
+
+    assert not _is_nested_under_package(skill_root, apm_modules)
