diff --git a/README.md b/README.md index acdad154e..4a401e8d7 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ dependencies: # Specific agent primitives from any repository - github/awesome-copilot/skills/review-and-refactor - github/awesome-copilot/agents/api-architect.agent.md + # Plugins (auto-detected from plugin.json) + - github/awesome-copilot/plugins/context-engineering ``` New developer joins the team: @@ -58,6 +60,7 @@ Skill registries install skills. APM manages **every primitive** your AI agents | **Prompts** | Reusable slash commands | `/security-audit`, `/design-review` | | **Agents** | Specialized personas | Accessibility auditor, API designer | | **Hooks** | Lifecycle event handlers | Pre-tool validation, post-tool linting | +| **Plugins** | Pre-packaged agent bundles | Context engineering, commit helpers | | **MCP Servers** | Tool integrations | Database access, API connectors | All declared in one manifest. All installed with one command — including transitive dependencies: @@ -89,6 +92,7 @@ pip install apm-cli apm install microsoft/apm-sample-package apm install anthropics/skills/skills/frontend-design apm install github/awesome-copilot/agents/api-architect.agent.md +apm install github/awesome-copilot/plugins/context-engineering ``` **Done.** Open your project in VS Code or Claude and your AI tools are ready. @@ -170,7 +174,7 @@ For private repos or Azure DevOps, set a token. For other hosts (GitLab, Bitbuck ## APM Packages -An APM package is anything you can point `apm install` at: a full package with an `apm.yml` manifest and `.apm/` folder, a single primitive file (`.instructions.md`, `.prompt.md`, `.agent.md`), a skill folder, or any subtree inside a repository. Hooks are auto-discovered when a package contains them. See [Primitives](docs/primitives.md) for details on each type. +An APM package is anything you can point `apm install` at: a full package with an `apm.yml` manifest and `.apm/` folder, a plugin with `plugin.json`, a single primitive file (`.instructions.md`, `.prompt.md`, `.agent.md`), a skill folder, or any subtree inside a repository. Hooks are auto-discovered when a package contains them. See [Primitives](docs/primitives.md) for details on each type. APM installs from **any git host** — GitHub, GitLab, Bitbucket, self-hosted servers, GitHub Enterprise, and Azure DevOps. Use HTTPS or SSH git URLs, or the `owner/repo` shorthand for GitHub. See [Package Sources](docs/getting-started.md#package-sources) for host configuration. @@ -193,7 +197,7 @@ See the [APM Roadmap](https://github.com/microsoft/apm/discussions/116) for what | | | |---|---| | **Get Started** | [Quick Start](docs/getting-started.md) · [Core Concepts](docs/concepts.md) · [Examples](docs/examples.md) | -| **Reference** | [CLI Reference](docs/cli-reference.md) · [Compilation Engine](docs/compilation.md) · [Skills](docs/skills.md) · [Integrations](docs/integrations.md) | +| **Reference** | [CLI Reference](docs/cli-reference.md) · [Compilation Engine](docs/compilation.md) · [Skills](docs/skills.md) · [Plugins](docs/plugins.md) · [Integrations](docs/integrations.md) | | **Advanced** | [Dependencies](docs/dependencies.md) · [Primitives](docs/primitives.md) · [Contributing](CONTRIBUTING.md) | --- diff --git a/docs/dependencies.md b/docs/dependencies.md index 9c79b7cf7..f59532a23 100644 --- a/docs/dependencies.md +++ b/docs/dependencies.md @@ -20,6 +20,7 @@ APM supports multiple dependency types: | Type | Detection | Example | |------|-----------|---------| | **APM Package** | Has `apm.yml` | `microsoft/apm-sample-package` | +| **Marketplace Plugin** | Has `plugin.json` (no `apm.yml`) | `github/awesome-copilot/plugins/context-engineering` | | **Claude Skill** | Has `SKILL.md` (no `apm.yml`) | `ComposioHQ/awesome-claude-skills/brand-guidelines` || **Hook Package** | Has `hooks/*.json` (no `apm.yml` or `SKILL.md`) | `anthropics/claude-plugins-official/plugins/hookify` || **Virtual Subdirectory Package** | Folder path in monorepo | `ComposioHQ/awesome-claude-skills/mcp-builder` | | **Virtual Subdirectory Package** | Folder path in repo | `github/awesome-copilot/skills/review-and-refactor` | | **ADO Package** | Azure DevOps repo | `dev.azure.com/org/project/_git/repo` | diff --git a/docs/manifest-schema.md b/docs/manifest-schema.md index 23feee4d8..22a9e91d9 100644 --- a/docs/manifest-schema.md +++ b/docs/manifest-schema.md @@ -120,10 +120,10 @@ Controls which output targets are generated during compilation. When unset, a co |---|---| | **Type** | `enum` | | **Required** | OPTIONAL | -| **Default** | None (unset — behaviour depends on package content) | +| **Default** | None (behaviour driven by package content; synthesized plugin manifests use `hybrid`) | | **Allowed values** | `instructions` · `skill` · `hybrid` · `prompts` | -Declares how the package's content is processed during install and compile: +Declares how the package's content is processed during install and compile. Currently behaviour is driven by package content (presence of `SKILL.md`, component directories, etc.); this field is reserved for future explicit overrides. | Value | Behaviour | |---|---| @@ -359,6 +359,7 @@ dependencies: # YAML list (not a map) is_virtual: # True for virtual (file/subdirectory) packages depth: # 1 = direct, 2+ = transitive resolved_by: # Parent dependency (transitive only) + package_type: # Package type (e.g. "apm_package", "marketplace_plugin") deployed_files: > # Workspace-relative paths of installed files mcp_servers: > # MCP dependency references managed by APM (OPTIONAL, e.g. "io.github.github/github-mcp-server") ``` diff --git a/docs/plugins.md b/docs/plugins.md index ed014a0fb..0c99eca4e 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -33,9 +33,9 @@ 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) + 1. `plugin.json` (root) + 2. `.github/plugin/plugin.json` (GitHub Copilot format) + 3. `.claude-plugin/plugin.json` (Claude format) 3. **Map Artifacts** - Plugin primitives from the repository root are mapped into `.apm/`: - `agents/` → `.apm/agents/` - `skills/` → `.apm/skills/` @@ -63,7 +63,7 @@ APM supports multiple plugin manifest locations to accommodate different platfor plugin-repo/ ├── .github/ │ └── plugin/ -│ └── plugin.json # GitHub Copilot location (highest priority) +│ └── plugin.json # GitHub Copilot location ├── agents/ │ └── agent-name.agent.md ├── skills/ @@ -78,22 +78,7 @@ plugin-repo/ ``` 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) +│ └── plugin.json # Claude location ├── agents/ │ └── agent-name.agent.md ├── skills/ @@ -107,7 +92,7 @@ plugin-repo/ #### Root Format ``` plugin-repo/ -├── plugin.json # Root location (lowest priority) +├── plugin.json # Root location (checked first) ├── agents/ │ └── agent-name.agent.md ├── skills/ @@ -127,7 +112,7 @@ plugin-repo/ ### plugin.json Manifest -Required fields: +Only `name` is required. `version` and `description` are optional metadata: ```json { @@ -155,6 +140,25 @@ Optional fields: } ``` +#### Custom component paths + +By default APM looks for `agents/`, `skills/`, `commands/`, and `hooks/` directories at the plugin root. You can override these with custom paths using strings or arrays: + +```json +{ + "name": "my-plugin", + "agents": ["./agents/planner.md", "./agents/coder.md"], + "skills": ["./skills/analysis", "./skills/review"], + "commands": "my-commands/", + "hooks": "hooks.json" +} +``` + +- **String** — single directory or file path +- **Array** — list of directories or individual files +- When an array contains directories, each is preserved as a named subdirectory (e.g., `./skills/analysis/` → `.apm/skills/analysis/SKILL.md`) +- `hooks` also accepts an inline object: `"hooks": {"hooks": {"PreToolUse": [...]}}` + ## Examples ### Installing Plugins from GitHub @@ -266,12 +270,12 @@ Once found, install them using the standard `apm install owner/repo/plugin-name` If APM doesn't recognize your plugin: -1. Check `plugin.json` exists at the repository root or in a subdirectory: - - `plugin.json` (root ) +1. Check `plugin.json` exists in one of the checked locations: + - `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` +3. Ensure `name` field is present (only required field) 4. Verify primitives are at the repository root (`agents/`, `skills/`, `commands/`) ### Version Resolution Issues diff --git a/docs/primitives.md b/docs/primitives.md index a629d5a91..e6c8b885f 100644 --- a/docs/primitives.md +++ b/docs/primitives.md @@ -76,6 +76,7 @@ The APM CLI supports the following types of primitives: - **Skills** (`SKILL.md`) - Package meta-guides that help AI agents understand what a package does - **Context** (`.context.md`, `.memory.md`) - Supply background information and project context - **Hooks** (`.json` in `.apm/hooks/` or `hooks/`) - Define lifecycle event handlers with script references +- **Plugins** (`plugin.json`) - Pre-packaged agent bundles auto-normalized into APM packages > **Note**: Both `.agent.md` (new format) and `.chatmode.md` (legacy format) are fully supported. VSCode provides Quick Fix actions to help migrate from `.chatmode.md` to `.agent.md`. diff --git a/src/apm_cli/cli.py b/src/apm_cli/cli.py index 50f47e92f..039ccba5e 100644 --- a/src/apm_cli/cli.py +++ b/src/apm_cli/cli.py @@ -1849,6 +1849,7 @@ def _collect_descendants(node): from apm_cli.deps.lockfile import LockFile, LockedDependency, get_lockfile_path installed_packages: List[tuple] = [] # List of (dep_ref, resolved_commit, depth, resolved_by) package_deployed_files: dict = {} # dep_key → list of relative deployed paths + package_types: dict = {} # dep_key → package type string # Build managed_files from existing lockfile for collision detection managed_files = builtins.set() @@ -2003,6 +2004,7 @@ def _collect_descendants(node): ) ref_str = f" @{dep_ref.reference}" if dep_ref.reference else "" _rich_info(f"✓ {display_name}{ref_str} (cached)") + installed_count += 1 # Still need to integrate prompts for cached packages (zero-config behavior) if integrate_vscode or integrate_claude: @@ -2053,7 +2055,11 @@ def _collect_descendants(node): # skill integration is not silently skipped skill_md_exists = (install_path / "SKILL.md").exists() apm_yml_exists = (install_path / "apm.yml").exists() - if skill_md_exists and apm_yml_exists: + from apm_cli.utils.helpers import find_plugin_json + plugin_json_exists = find_plugin_json(install_path) is not None + if plugin_json_exists and not apm_yml_exists: + cached_package_info.package_type = PackageType.MARKETPLACE_PLUGIN + elif skill_md_exists and apm_yml_exists: cached_package_info.package_type = PackageType.HYBRID elif skill_md_exists: cached_package_info.package_type = PackageType.CLAUDE_SKILL @@ -2076,6 +2082,10 @@ def _collect_descendants(node): installed_packages.append((dep_ref, cached_commit, depth, resolved_by)) dep_deployed: list = [] # collect deployed paths for this package + # Track package type for lockfile + if hasattr(cached_package_info, 'package_type') and cached_package_info.package_type: + package_types[dep_key] = cached_package_info.package_type.value + # VSCode + Claude integration (prompts + agents) if integrate_vscode or integrate_claude: # Integrate prompts @@ -2299,6 +2309,10 @@ def _collect_descendants(node): installed_packages.append((dep_ref, resolved_commit, depth, resolved_by)) dep_deployed_fresh: list = [] # collect deployed paths for this package + # Track package type for lockfile + if hasattr(package_info, 'package_type') and package_info.package_type: + package_types[dep_ref.get_unique_key()] = package_info.package_type.value + # Show package type in verbose mode if verbose and hasattr(package_info, "package_type"): from apm_cli.models.apm_package import PackageType @@ -2499,11 +2513,20 @@ def _collect_descendants(node): if installed_packages: try: lockfile = LockFile.from_installed_packages(installed_packages, dependency_graph) - # Attach deployed_files to each LockedDependency + # Attach deployed_files and package_type to each LockedDependency for dep_key, dep_files in package_deployed_files.items(): if dep_key in lockfile.dependencies: lockfile.dependencies[dep_key].deployed_files = dep_files - + for dep_key, pkg_type in package_types.items(): + if dep_key in lockfile.dependencies: + lockfile.dependencies[dep_key].package_type = pkg_type + # Merge with existing lockfile to preserve entries for packages + # not processed in this run (e.g. `apm install X` only installs X). + # Skip merge when update_refs is set — stale entries must not survive. + if existing_lockfile and not update_refs: + for dep_key, dep in existing_lockfile.dependencies.items(): + if dep_key not in lockfile.dependencies: + lockfile.dependencies[dep_key] = dep lockfile_path = get_lockfile_path(project_root) # When installing a subset of packages (apm install ), diff --git a/src/apm_cli/commands/deps.py b/src/apm_cli/commands/deps.py index 305a99ae0..86ab1084b 100644 --- a/src/apm_cli/commands/deps.py +++ b/src/apm_cli/commands/deps.py @@ -473,18 +473,25 @@ def info(package: str): _rich_info("Run 'apm install' to install dependencies first") sys.exit(1) - # Find the package directory - handle org/repo structure + # Find the package directory - handle org/repo and deep sub-path structures package_path = None - for org_dir in apm_modules_path.iterdir(): - if org_dir.is_dir() and not org_dir.name.startswith('.'): - for package_dir in org_dir.iterdir(): - if package_dir.is_dir() and not package_dir.name.startswith('.'): - # Check both package name and org/package format - if package_dir.name == package or f"{org_dir.name}/{package_dir.name}" == package: - package_path = package_dir - break - if package_path: - break + # First try direct path match (handles any depth: org/repo, org/repo/subdir/pkg) + direct_match = apm_modules_path / package + if direct_match.is_dir() and ( + (direct_match / "apm.yml").exists() or (direct_match / "SKILL.md").exists() + ): + package_path = direct_match + else: + # Fallback: scan org/repo structure (2-level) for short package names + for org_dir in apm_modules_path.iterdir(): + if org_dir.is_dir() and not org_dir.name.startswith('.'): + for package_dir in org_dir.iterdir(): + if package_dir.is_dir() and not package_dir.name.startswith('.'): + if package_dir.name == package or f"{org_dir.name}/{package_dir.name}" == package: + package_path = package_dir + break + if package_path: + break if not package_path: _rich_error(f"Package '{package}' not found in apm_modules/") diff --git a/src/apm_cli/deps/lockfile.py b/src/apm_cli/deps/lockfile.py index e7cb370e0..daaba6ac6 100644 --- a/src/apm_cli/deps/lockfile.py +++ b/src/apm_cli/deps/lockfile.py @@ -26,6 +26,7 @@ class LockedDependency: is_virtual: bool = False depth: int = 1 resolved_by: Optional[str] = None + package_type: Optional[str] = None deployed_files: List[str] = field(default_factory=list) def get_unique_key(self) -> str: @@ -53,6 +54,8 @@ def to_dict(self) -> Dict[str, Any]: result["depth"] = self.depth if self.resolved_by: result["resolved_by"] = self.resolved_by + if self.package_type: + result["package_type"] = self.package_type if self.deployed_files: result["deployed_files"] = sorted(self.deployed_files) return result @@ -84,6 +87,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "LockedDependency": is_virtual=data.get("is_virtual", False), depth=data.get("depth", 1), resolved_by=data.get("resolved_by"), + package_type=data.get("package_type"), deployed_files=deployed_files, ) diff --git a/src/apm_cli/deps/plugin_parser.py b/src/apm_cli/deps/plugin_parser.py index 91fd6d1b4..fdaf440ab 100644 --- a/src/apm_cli/deps/plugin_parser.py +++ b/src/apm_cli/deps/plugin_parser.py @@ -12,6 +12,7 @@ """ import json +import logging import shutil from pathlib import Path from typing import Dict, Any, Optional @@ -40,6 +41,12 @@ def parse_plugin_manifest(plugin_json_path: Path) -> Dict[str, Any]: except json.JSONDecodeError as e: raise ValueError(f"Invalid JSON in plugin.json: {e}") + if not manifest.get('name'): + logging.getLogger("apm").warning( + "plugin.json at %s is missing 'name' field; falling back to directory name", + plugin_json_path, + ) + return manifest @@ -99,7 +106,7 @@ def synthesize_apm_yml_from_plugin(plugin_path: Path, manifest: Dict[str, Any]) apm_dir.mkdir(exist_ok=True) # Map plugin structure into .apm/ subdirectories - _map_plugin_artifacts(plugin_path, apm_dir) + _map_plugin_artifacts(plugin_path, apm_dir, manifest) # Generate apm.yml from plugin metadata apm_yml_content = _generate_apm_yml(manifest) @@ -111,65 +118,162 @@ def synthesize_apm_yml_from_plugin(plugin_path: Path, manifest: Dict[str, Any]) return apm_yml_path -def _map_plugin_artifacts(plugin_path: Path, apm_dir: Path) -> None: +def _ignore_symlinks(directory, contents): + """Ignore function for shutil.copytree that skips symlinks.""" + return [name for name in contents if (Path(directory) / name).is_symlink()] + + +def _map_plugin_artifacts(plugin_path: Path, apm_dir: Path, manifest: Optional[Dict[str, Any]] = None) -> 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/ + - hooks/ → .apm/hooks/ (directory, config file, or inline object) - .mcp.json → .apm/.mcp.json (MCP-based plugins need this to function) - .lsp.json → .apm/.lsp.json - settings.json → .apm/settings.json + When the manifest specifies custom component paths (e.g. ``"agents": ["custom/"]``), + those paths are used instead of the defaults. + + Symlinks are skipped entirely to prevent content exfiltration attacks. + Args: plugin_path: Root of the plugin directory. apm_dir: Path to the .apm/ directory. + manifest: Optional plugin.json metadata; used for custom component paths. """ + if manifest is None: + manifest = {} + + # Resolve source paths — use manifest arrays if present, else defaults. + # Custom paths may be directories OR individual files. + def _resolve_sources(component: str, default_dir: str): + """Return list of existing source paths (dirs or files) for a component.""" + custom = manifest.get(component) + if isinstance(custom, list): + paths = [] + for p in custom: + src = plugin_path / str(p) + if src.exists() and not src.is_symlink(): + paths.append(src) + return paths + elif isinstance(custom, str): + src = plugin_path / custom + return [src] if src.exists() and not src.is_symlink() else [] + default = plugin_path / default_dir + return [default] if default.exists() and default.is_dir() else [] + # Map agents/ - source_agents = plugin_path / "agents" - if source_agents.exists() and source_agents.is_dir(): + agent_sources = _resolve_sources("agents", "agents") + if agent_sources: target_agents = apm_dir / "agents" if target_agents.exists(): shutil.rmtree(target_agents) - shutil.copytree(source_agents, target_agents, symlinks=False) + agent_dirs = [s for s in agent_sources if s.is_dir()] + agent_files = [s for s in agent_sources if s.is_file()] + # Array of directories → each is a named component; preserve dir name. + # Single/default directories → copy contents as root. + is_custom_list = isinstance(manifest.get("agents"), list) + if is_custom_list and agent_dirs: + target_agents.mkdir(parents=True, exist_ok=True) + for d in agent_dirs: + shutil.copytree( + d, target_agents / d.name, + ignore=_ignore_symlinks, dirs_exist_ok=True, + ) + elif agent_dirs: + shutil.copytree(agent_dirs[0], target_agents, ignore=_ignore_symlinks) + for extra in agent_dirs[1:]: + shutil.copytree(extra, target_agents, dirs_exist_ok=True, ignore=_ignore_symlinks) + if agent_files: + target_agents.mkdir(parents=True, exist_ok=True) + for f in agent_files: + shutil.copy2(f, target_agents / f.name) # Map skills/ - source_skills = plugin_path / "skills" - if source_skills.exists() and source_skills.is_dir(): + skill_sources = _resolve_sources("skills", "skills") + if skill_sources: target_skills = apm_dir / "skills" if target_skills.exists(): shutil.rmtree(target_skills) - shutil.copytree(source_skills, target_skills, symlinks=False) + skill_dirs = [s for s in skill_sources if s.is_dir()] + skill_files = [s for s in skill_sources if s.is_file()] + is_custom_list = isinstance(manifest.get("skills"), list) + if is_custom_list and skill_dirs: + target_skills.mkdir(parents=True, exist_ok=True) + for d in skill_dirs: + shutil.copytree( + d, target_skills / d.name, + ignore=_ignore_symlinks, dirs_exist_ok=True, + ) + elif skill_dirs: + shutil.copytree(skill_dirs[0], target_skills, ignore=_ignore_symlinks) + for extra in skill_dirs[1:]: + shutil.copytree(extra, target_skills, dirs_exist_ok=True, ignore=_ignore_symlinks) + if skill_files: + target_skills.mkdir(parents=True, exist_ok=True) + for f in skill_files: + shutil.copy2(f, target_skills / f.name) # Map commands/ → .apm/prompts/ (normalize .md → .prompt.md) - source_commands = plugin_path / "commands" - if source_commands.exists() and source_commands.is_dir(): + command_sources = _resolve_sources("commands", "commands") + if command_sources: target_prompts = apm_dir / "prompts" if target_prompts.exists(): shutil.rmtree(target_prompts) target_prompts.mkdir(parents=True, exist_ok=True) - for source_file in source_commands.rglob("*"): - 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 - if source_file.name.endswith(".prompt.md"): - pass - elif source_file.suffix == ".md": + def _copy_command_file(source_file: Path, dest_dir: Path, rel_to: Path = None): + """Copy a command file, normalizing .md → .prompt.md.""" + if rel_to: + relative_path = source_file.relative_to(rel_to) + target_path = dest_dir / relative_path + else: + target_path = dest_dir / source_file.name + if not source_file.name.endswith(".prompt.md") and 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(): + for source in command_sources: + if source.is_file() and not source.is_symlink(): + _copy_command_file(source, target_prompts) + elif source.is_dir(): + for source_file in source.rglob("*"): + if not source_file.is_file() or source_file.is_symlink(): + continue + _copy_command_file(source_file, target_prompts, rel_to=source) + + # Map hooks/ — the spec allows a directory path, a config file path, + # or an inline object. Handle all three forms. + hooks_value = manifest.get("hooks") + if isinstance(hooks_value, dict): + # Inline hooks object → write as .apm/hooks/hooks.json + target_hooks = apm_dir / "hooks" + target_hooks.mkdir(parents=True, exist_ok=True) + (target_hooks / "hooks.json").write_text( + json.dumps(hooks_value, indent=2) + ) + elif isinstance(hooks_value, str) and (plugin_path / hooks_value).is_file(): + # Config file path (e.g. "hooks": "hooks.json") target_hooks = apm_dir / "hooks" - if target_hooks.exists(): - shutil.rmtree(target_hooks) - shutil.copytree(source_hooks, target_hooks, symlinks=False) + target_hooks.mkdir(parents=True, exist_ok=True) + src_file = plugin_path / hooks_value + if not src_file.is_symlink(): + shutil.copy2(src_file, target_hooks / "hooks.json") + else: + # Directory path(s) — standard flow + hook_sources = _resolve_sources("hooks", "hooks") + if hook_sources: + target_hooks = apm_dir / "hooks" + if target_hooks.exists(): + shutil.rmtree(target_hooks) + shutil.copytree(hook_sources[0], target_hooks, ignore=_ignore_symlinks) + for extra in hook_sources[1:]: + shutil.copytree(extra, target_hooks, dirs_exist_ok=True, ignore=_ignore_symlinks) # Pass-through files required for MCP/LSP plugins to function for passthrough in (".mcp.json", ".lsp.json", "settings.json"): @@ -208,6 +312,8 @@ def _generate_apm_yml(manifest: Dict[str, Any]) -> str: if manifest.get('dependencies'): apm_package['dependencies'] = {'apm': manifest['dependencies']} + # Install behavior is driven by file presence (SKILL.md, etc.), not this + # field. Default to hybrid so the standard pipeline handles all components. apm_package['type'] = 'hybrid' return yaml.dump(apm_package, default_flow_style=False, sort_keys=False) diff --git a/src/apm_cli/integration/agent_integrator.py b/src/apm_cli/integration/agent_integrator.py index 6eafbc4fa..51665f6a5 100644 --- a/src/apm_cli/integration/agent_integrator.py +++ b/src/apm_cli/integration/agent_integrator.py @@ -39,6 +39,16 @@ def find_agent_files(self, package_path: Path) -> List[Path]: apm_agents = package_path / ".apm" / "agents" if apm_agents.exists(): agent_files.extend(apm_agents.glob("*.agent.md")) + # Also pick up plain .md files in agents/; plugins may not use + # the .agent.md convention — the directory name already implies type + _NON_AGENT_NAMES = {"README.md", "CHANGELOG.md", "LICENSE.md", "CONTRIBUTING.md", "NOTES.md"} + for md_file in apm_agents.glob("*.md"): + if ( + not md_file.name.endswith(".agent.md") + and md_file.name not in _NON_AGENT_NAMES + and md_file not in agent_files + ): + agent_files.append(md_file) # Search in .apm/chatmodes/ (legacy) apm_chatmodes = package_path / ".apm" / "chatmodes" diff --git a/src/apm_cli/models/apm_package.py b/src/apm_cli/models/apm_package.py index b0bd11c67..0f376f2e0 100644 --- a/src/apm_cli/models/apm_package.py +++ b/src/apm_cli/models/apm_package.py @@ -1353,9 +1353,21 @@ def validate_apm_package(package_path: Path) -> ValidationResult: elif has_hooks: result.package_type = PackageType.HOOK_PACKAGE else: - # 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 + # Require plugin.json or at least one standard component directory + has_plugin_evidence = ( + plugin_json_path is not None + or (package_path / "agents").is_dir() + or (package_path / "skills").is_dir() + or (package_path / "commands").is_dir() + ) + if has_plugin_evidence: + result.package_type = PackageType.MARKETPLACE_PLUGIN + else: + result.add_error( + f"Not a valid APM package: no apm.yml, SKILL.md, hooks, or " + f"plugin structure found in {package_path.name}" + ) + return result # Handle hook-only packages (no apm.yml or SKILL.md) if result.package_type == PackageType.HOOK_PACKAGE: diff --git a/tests/integration/test_marketplace_plugin_integration.py b/tests/integration/test_marketplace_plugin_integration.py index aea2eff5d..dc17c465e 100644 --- a/tests/integration/test_marketplace_plugin_integration.py +++ b/tests/integration/test_marketplace_plugin_integration.py @@ -385,7 +385,7 @@ def test_mcp_json_copied_through(self, tmp_path): assert result.is_valid assert (plugin_dir / ".apm" / ".mcp.json").exists(), ".mcp.json must be copied to .apm/" - + def test_plugin_integrator_deployment(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" diff --git a/tests/integration/test_plugin_e2e.py b/tests/integration/test_plugin_e2e.py new file mode 100644 index 000000000..1a86b07b6 --- /dev/null +++ b/tests/integration/test_plugin_e2e.py @@ -0,0 +1,755 @@ +"""E2E integration tests for plugin support. + +Hero-scenario tests — no mocks for Class 1 (real filesystem), real network +for Class 2. Verifies the full plugin lifecycle from detection through +integrator deployment, orphan detection, and CLI round-trips. +""" + +import json +import os +import shutil +import subprocess +from datetime import datetime +from pathlib import Path + +import pytest + +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 apm_cli.models.apm_package import ( + APMPackage, + GitReferenceType, + PackageInfo, + PackageType, + ResolvedReference, + validate_apm_package, +) +from apm_cli.utils.helpers import find_plugin_json + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +FIXTURE_DIR = Path(__file__).parent.parent / "fixtures" / "mock-marketplace-plugin" + + +def _make_package_info(package: APMPackage, install_path: Path, package_type: PackageType) -> PackageInfo: + """Build a PackageInfo with a dummy resolved reference.""" + return PackageInfo( + package=package, + install_path=install_path, + resolved_reference=ResolvedReference( + original_ref="main", + ref_type=GitReferenceType.BRANCH, + resolved_commit="abcdef1234567890abcdef1234567890abcdef12", + ref_name="main", + ), + installed_at=datetime.now().isoformat(), + package_type=package_type, + ) + + +def _run_integrators(package_info: PackageInfo, project_root: Path): + """Run all four integrators against a package.""" + 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) + command_result = CommandIntegrator().integrate_package_commands(package_info, project_root) + return prompt_result, agent_result, skill_result, command_result + + +# =========================================================================== +# Class 1 — LOCAL tests (no network, uses mock fixture) +# =========================================================================== + + +class TestPluginHeroScenarios: + """Local hero-scenario tests using the mock-marketplace-plugin fixture.""" + + # ---- Test 1: Full lifecycle ----------------------------------------- + + def test_full_lifecycle_install_to_deploy(self, tmp_path): + """Complete hero: detect → validate → normalize → integrate → deploy.""" + if not FIXTURE_DIR.exists(): + pytest.skip("mock-marketplace-plugin fixture not found") + + # 1. Copy fixture & validate + plugin_dir = tmp_path / "mock-marketplace-plugin" + shutil.copytree(FIXTURE_DIR, plugin_dir) + + result = validate_apm_package(plugin_dir) + assert result.package_type == PackageType.MARKETPLACE_PLUGIN + assert result.is_valid, f"Validation errors: {result.errors}" + assert result.package is not None + + # 2. Verify synthesized apm.yml + assert (plugin_dir / "apm.yml").exists(), "apm.yml should be synthesized" + + # 3. Verify .apm/ structure + apm_dir = plugin_dir / ".apm" + assert apm_dir.exists() + assert (apm_dir / "agents" / "test-agent.agent.md").exists() + assert (apm_dir / "skills" / "test-skill" / "SKILL.md").exists() + assert (apm_dir / "prompts" / "test-command.prompt.md").exists() + + # 4. Set up a project root and run integrators + project_root = tmp_path / "project" + project_root.mkdir() + (project_root / ".github").mkdir() + + pkg_info = _make_package_info(result.package, plugin_dir, result.package_type) + prompt_r, agent_r, skill_r, command_r = _run_integrators(pkg_info, project_root) + + # 5. Assert scattered files + assert (project_root / ".github" / "prompts" / "test-command.prompt.md").exists() + assert (project_root / ".github" / "agents" / "test-agent.agent.md").exists() + assert (project_root / ".github" / "skills" / "test-skill" / "SKILL.md").exists() + + # ---- Test 2: No false orphans after install ------------------------- + + def test_no_false_orphans_after_install(self, tmp_path): + """Scattered sub-skills must NOT appear as independent packages.""" + if not FIXTURE_DIR.exists(): + pytest.skip("mock-marketplace-plugin fixture not found") + + project_root = tmp_path / "project" + project_root.mkdir() + (project_root / ".github").mkdir() + + # Create apm.yml declaring the plugin as a dependency + apm_yml = project_root / "apm.yml" + apm_yml.write_text( + "name: orphan-test\n" + "version: 1.0.0\n" + "dependencies:\n" + " apm:\n" + " - microsoft/apm-test-plugin\n" + ) + + # Install the fixture into apm_modules + apm_modules = project_root / "apm_modules" / "microsoft" / "apm-test-plugin" + apm_modules.mkdir(parents=True) + shutil.copytree(FIXTURE_DIR, apm_modules, dirs_exist_ok=True) + + # Validate and integrate + result = validate_apm_package(apm_modules) + assert result.is_valid + pkg_info = _make_package_info(result.package, apm_modules, result.package_type) + _run_integrators(pkg_info, project_root) + + # Walk apm_modules the same way deps.py list_packages() does + modules_root = project_root / "apm_modules" + false_orphans = [] + for candidate in modules_root.rglob("*"): + if not candidate.is_dir() or candidate.name.startswith("."): + continue + has_apm = (candidate / "apm.yml").exists() + has_skill = (candidate / "SKILL.md").exists() + if not has_apm and not has_skill: + continue + rel_parts = candidate.relative_to(modules_root).parts + if len(rel_parts) < 2: + continue + # Skip sub-components inside .apm/ + if ".apm" in rel_parts: + continue + # Skip sub-components nested inside a parent with apm.yml + if has_skill and not has_apm: + is_sub = False + check = candidate.parent + while check != modules_root and check != check.parent: + if (check / "apm.yml").exists(): + is_sub = True + break + check = check.parent + if is_sub: + continue + org_repo = "/".join(rel_parts) + if org_repo != "microsoft/apm-test-plugin": + false_orphans.append(org_repo) + + assert false_orphans == [], f"False orphan packages detected: {false_orphans}" + + # ---- Test 3: Empty dir rejected ------------------------------------- + + def test_empty_dir_rejected(self, tmp_path): + """An empty directory must not validate as a valid package.""" + empty_dir = tmp_path / "empty-plugin" + empty_dir.mkdir() + + result = validate_apm_package(empty_dir) + assert not result.is_valid, "Empty directory should be invalid" + assert len(result.errors) > 0 + + # ---- Test 4: Symlinks not followed ---------------------------------- + + def test_symlinks_not_followed(self, tmp_path): + """Symlinks inside plugin dirs must NOT be dereferenced during copytree.""" + plugin_dir = tmp_path / "symlink-plugin" + plugin_dir.mkdir() + + # plugin.json so it's detected as a plugin + (plugin_dir / "plugin.json").write_text(json.dumps({ + "name": "Symlink Plugin", + "version": "1.0.0", + "description": "Plugin with a symlink" + })) + + # agents/ with a symlink pointing at an external file + agents_dir = plugin_dir / "agents" + agents_dir.mkdir() + secret_file = tmp_path / "secret.txt" + secret_file.write_text("TOP SECRET DATA") + os.symlink(str(secret_file), str(agents_dir / "secret-link.txt")) + + result = validate_apm_package(plugin_dir) + assert result.package_type == PackageType.MARKETPLACE_PLUGIN + + # After normalization, .apm/agents/ should have the symlink as-is + # (or not at all) — never the resolved target content + apm_agents = plugin_dir / ".apm" / "agents" + if apm_agents.exists(): + for f in apm_agents.iterdir(): + if f.name == "secret-link.txt": + # Either it's still a symlink or it was skipped — both OK. + # What is NOT OK is that it's a regular file with "TOP SECRET". + if f.is_file() and not f.is_symlink(): + content = f.read_text() + assert "TOP SECRET" not in content, ( + "Symlink was followed and target content leaked into .apm/" + ) + + # ---- Test 5: find_plugin_json deterministic ------------------------- + + def test_find_plugin_json_deterministic(self, tmp_path): + """Root plugin.json wins; node_modules is never found.""" + pkg = tmp_path / "plugin-prio" + pkg.mkdir() + + # Root plugin.json + (pkg / "plugin.json").write_text('{"name":"root"}') + + # .github/plugin/plugin.json + (pkg / ".github" / "plugin").mkdir(parents=True) + (pkg / ".github" / "plugin" / "plugin.json").write_text('{"name":"github"}') + + # Deeply nested node_modules — should never be found + nm = pkg / "node_modules" / "foo" + nm.mkdir(parents=True) + (nm / "plugin.json").write_text('{"name":"node_modules"}') + + found = find_plugin_json(pkg) + assert found is not None + assert found == pkg / "plugin.json", "Root plugin.json should win" + + # Remove root, .github/plugin/ should win next + (pkg / "plugin.json").unlink() + found2 = find_plugin_json(pkg) + assert found2 == pkg / ".github" / "plugin" / "plugin.json" + + # Remove .github/plugin/ version — node_modules should NOT be found + (pkg / ".github" / "plugin" / "plugin.json").unlink() + found3 = find_plugin_json(pkg) + assert found3 is None, "node_modules plugin.json must never be discovered" + + # ---- Test 6: deps info virtual subpath ------------------------------ + + def test_deps_info_virtual_subpath(self, tmp_path): + """Direct-path lookup in deps info() resolves deep sub-path packages.""" + project_root = tmp_path / "project" + apm_modules = project_root / "apm_modules" + + # Set up a virtual subpath package (4-level deep) + pkg_path = apm_modules / "github" / "awesome-copilot" / "plugins" / "context-engineering" + pkg_path.mkdir(parents=True) + (pkg_path / "apm.yml").write_text( + "name: context-engineering\n" + "version: 2.0.0\n" + "description: Context engineering plugin\n" + ) + (pkg_path / "SKILL.md").write_text("---\nname: context-engineering\n---\n# Skill") + + # Exercise the same direct_match lookup that deps info() uses + package = "github/awesome-copilot/plugins/context-engineering" + direct_match = apm_modules / package + assert direct_match.is_dir() + assert ( + (direct_match / "apm.yml").exists() + or (direct_match / "SKILL.md").exists() + ), "direct_match lookup must find deep sub-path package" + + # Parse the resolved package + pkg = APMPackage.from_apm_yml(direct_match / "apm.yml") + assert pkg.name == "context-engineering" + assert pkg.version == "2.0.0" + + # ---- Test 7: compile discovers plugin primitives -------------------- + + def test_compile_discovers_plugin_primitives(self, tmp_path): + """Compile primitive discovery should find .apm/ content from plugins.""" + from apm_cli.primitives.discovery import discover_primitives_with_dependencies + + if not FIXTURE_DIR.exists(): + pytest.skip("mock-marketplace-plugin fixture not found") + + project_root = tmp_path / "project" + project_root.mkdir() + (project_root / ".github").mkdir() + + # Set up apm_modules with the plugin + pkg_dir = project_root / "apm_modules" / "microsoft" / "apm-test-plugin" + pkg_dir.mkdir(parents=True) + shutil.copytree(FIXTURE_DIR, pkg_dir, dirs_exist_ok=True) + + # Validate to trigger normalization (creates .apm/ and apm.yml) + result = validate_apm_package(pkg_dir) + assert result.is_valid + + # Create apm.yml declaring the dependency + (project_root / "apm.yml").write_text( + "name: compile-test\nversion: 1.0.0\n" + "dependencies:\n apm:\n - microsoft/apm-test-plugin\n" + ) + + # Run primitive discovery (what compile uses internally) + collection = discover_primitives_with_dependencies(str(project_root)) + + # Plugin primitives should appear (agents/ and any instructions in .apm/) + all_sources = [p.source for p in collection.all_primitives()] + has_dep_source = any("dependency:microsoft/apm-test-plugin" in s for s in all_sources) + assert has_dep_source, ( + f"Plugin primitives not discovered. Sources found: {all_sources}" + ) + + # ---- Test 8: lockfile package_type round-trip ----------------------- + + def test_lockfile_package_type_roundtrip(self, tmp_path): + """LockedDependency.package_type serializes and deserializes correctly.""" + from apm_cli.deps.lockfile import LockedDependency + + dep = LockedDependency( + repo_url="microsoft/apm-test-plugin", + resolved_commit="abc123", + package_type="marketplace_plugin", + deployed_files=[".github/agents/test.agent.md"], + ) + + serialized = dep.to_dict() + assert serialized["package_type"] == "marketplace_plugin" + + restored = LockedDependency.from_dict(serialized) + assert restored.package_type == "marketplace_plugin" + assert restored.deployed_files == [".github/agents/test.agent.md"] + + # ---- Test 9: generated apm.yml always has type: hybrid -------------- + + def test_generated_apm_yml_type_is_hybrid(self, tmp_path): + """Synthesized apm.yml should always emit type: hybrid (dead metadata).""" + import yaml as yaml_lib + + if not FIXTURE_DIR.exists(): + pytest.skip("mock-marketplace-plugin fixture not found") + + plugin_dir = tmp_path / "type-test-plugin" + shutil.copytree(FIXTURE_DIR, plugin_dir) + + result = validate_apm_package(plugin_dir) + assert result.is_valid + + parsed = yaml_lib.safe_load((plugin_dir / "apm.yml").read_text()) + assert parsed["type"] == "hybrid", ( + f"Expected type 'hybrid', got '{parsed.get('type')}'" + ) + + +# =========================================================================== +# Class 2 — NETWORK E2E tests (real CLI, requires GitHub token) +# =========================================================================== + +pytestmark_network = pytest.mark.skipif( + not os.environ.get("GITHUB_APM_PAT") and not os.environ.get("GITHUB_TOKEN"), + reason="GITHUB_APM_PAT or GITHUB_TOKEN required for GitHub API access", +) + + +@pytest.fixture +def apm_command(): + """Get the path to the APM CLI executable.""" + apm_on_path = shutil.which("apm") + if apm_on_path: + return apm_on_path + venv_apm = Path(__file__).parent.parent.parent / ".venv" / "bin" / "apm" + if venv_apm.exists(): + return str(venv_apm) + return "apm" + + +@pytest.fixture +def temp_project(tmp_path): + """Create a temporary APM project.""" + project_dir = tmp_path / "e2e-project" + project_dir.mkdir() + apm_yml = project_dir / "apm.yml" + apm_yml.write_text( + "name: e2e-test-project\n" + "version: 1.0.0\n" + "description: E2E test project for plugin support\n" + "dependencies:\n" + " apm: []\n" + ) + (project_dir / ".github").mkdir() + return project_dir + + +@pytestmark_network +class TestPluginNetworkE2E: + """Network E2E tests — real CLI installs from GitHub.""" + + PLUGIN_REF = "github/awesome-copilot/plugins/context-engineering" + + # ---- Test 1: install real plugin ------------------------------------ + + def test_install_real_plugin(self, apm_command, temp_project): + """Install a real plugin from GitHub, verify artifacts on disk.""" + result = subprocess.run( + [apm_command, "install", self.PLUGIN_REF, "--verbose"], + capture_output=True, text=True, cwd=str(temp_project), timeout=180, + ) + assert result.returncode == 0, ( + f"apm install failed (rc={result.returncode}):\n" + f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" + ) + + # Installed directory exists + pkg_path = temp_project / "apm_modules" / self.PLUGIN_REF + assert pkg_path.is_dir(), f"Expected {pkg_path} to exist" + + # apm.yml synthesized + assert (pkg_path / "apm.yml").exists(), "apm.yml should be synthesized" + + # Lock file created + assert (temp_project / "apm.lock").exists(), "apm.lock should be created" + + # Skills scattered to .github/skills/ + skills_dir = temp_project / ".github" / "skills" + if skills_dir.exists(): + skill_dirs = [d for d in skills_dir.iterdir() if d.is_dir()] + assert len(skill_dirs) > 0, "At least one skill should be scattered" + + # ---- Test 2: deps list — no false orphans --------------------------- + + def test_deps_list_no_false_orphans(self, apm_command, temp_project): + """After install, deps list should show the plugin without orphan warnings.""" + # Install first + subprocess.run( + [apm_command, "install", self.PLUGIN_REF, "--verbose"], + capture_output=True, text=True, cwd=str(temp_project), timeout=180, + ) + + result = subprocess.run( + [apm_command, "deps", "list"], + capture_output=True, text=True, cwd=str(temp_project), timeout=60, + ) + assert result.returncode == 0, ( + f"deps list failed (rc={result.returncode}):\n{result.stderr}" + ) + + combined = result.stdout + result.stderr + assert "orphan" not in combined.lower(), ( + f"False orphan detected in deps list output:\n{combined}" + ) + + # ---- Test 3: deps tree shows plugin --------------------------------- + + def test_deps_tree_shows_plugin(self, apm_command, temp_project): + """deps tree output should contain the plugin reference.""" + subprocess.run( + [apm_command, "install", self.PLUGIN_REF, "--verbose"], + capture_output=True, text=True, cwd=str(temp_project), timeout=180, + ) + + result = subprocess.run( + [apm_command, "deps", "tree"], + capture_output=True, text=True, cwd=str(temp_project), timeout=60, + ) + assert result.returncode == 0, ( + f"deps tree failed (rc={result.returncode}):\n{result.stderr}" + ) + + combined = result.stdout + result.stderr + assert "context-engineering" in combined, ( + f"Plugin not found in deps tree output:\n{combined}" + ) + + # ---- Test 4: mixed dependencies (plugin + skill) -------------------- + + def test_install_mixed_dependencies(self, apm_command, temp_project): + """Install a plugin AND a regular skill together.""" + apm_yml = temp_project / "apm.yml" + apm_yml.write_text( + "name: e2e-test-project\n" + "version: 1.0.0\n" + "dependencies:\n" + " apm:\n" + f" - {self.PLUGIN_REF}\n" + " - github/awesome-copilot/skills/review-and-refactor\n" + ) + + result = subprocess.run( + [apm_command, "install", "--verbose"], + capture_output=True, text=True, cwd=str(temp_project), timeout=180, + ) + assert result.returncode == 0, ( + f"mixed install failed (rc={result.returncode}):\n" + f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" + ) + + # Both packages installed + assert (temp_project / "apm_modules" / self.PLUGIN_REF).is_dir() + review_path = ( + temp_project / "apm_modules" / "github" / "awesome-copilot" + / "skills" / "review-and-refactor" + ) + # The skill may be installed as a virtual subdir or flattened — check either + skill_installed = review_path.is_dir() or any( + (temp_project / "apm_modules" / "github").rglob("review-and-refactor") + ) + assert skill_installed, "review-and-refactor skill should be installed" + + # deps list — no orphans + list_result = subprocess.run( + [apm_command, "deps", "list"], + capture_output=True, text=True, cwd=str(temp_project), timeout=60, + ) + combined = list_result.stdout + list_result.stderr + assert "orphan" not in combined.lower(), ( + f"False orphan in mixed install:\n{combined}" + ) + + # ---- Test 5: uninstall plugin --------------------------------------- + + def test_uninstall_plugin(self, apm_command, temp_project): + """Uninstall a plugin — directory and scattered files cleaned up.""" + # Install first + subprocess.run( + [apm_command, "install", self.PLUGIN_REF, "--verbose"], + capture_output=True, text=True, cwd=str(temp_project), timeout=180, + ) + pkg_path = temp_project / "apm_modules" / self.PLUGIN_REF + assert pkg_path.is_dir(), "Plugin must be installed before uninstall test" + + # Uninstall + result = subprocess.run( + [apm_command, "uninstall", self.PLUGIN_REF], + capture_output=True, text=True, cwd=str(temp_project), timeout=60, + ) + assert result.returncode == 0, ( + f"uninstall failed (rc={result.returncode}):\n" + f"STDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" + ) + + # Package directory should be gone + assert not pkg_path.exists(), "Plugin directory should be removed after uninstall" + + # ---- Test 6: lockfile preserved on sequential installs --------------- + + def test_lockfile_preserved_on_sequential_install(self, apm_command, temp_project): + """Installing packages one at a time must preserve previous lockfile entries.""" + skill_ref = "github/awesome-copilot/skills/review-and-refactor" + + # Install plugin + r1 = subprocess.run( + [apm_command, "install", self.PLUGIN_REF], + capture_output=True, text=True, cwd=str(temp_project), timeout=180, + ) + assert r1.returncode == 0, f"First install failed:\n{r1.stderr}" + + # Install skill separately + r2 = subprocess.run( + [apm_command, "install", skill_ref], + capture_output=True, text=True, cwd=str(temp_project), timeout=180, + ) + assert r2.returncode == 0, f"Second install failed:\n{r2.stderr}" + + # Lockfile should contain BOTH entries + import yaml + lockfile = yaml.safe_load((temp_project / "apm.lock").read_text()) + dep_keys = { + f"{d['repo_url']}/{d.get('virtual_path', '')}" for d in lockfile["dependencies"] + } + assert "github/awesome-copilot/plugins/context-engineering" in dep_keys, ( + f"Plugin missing from lockfile after sequential install. Keys: {dep_keys}" + ) + assert "github/awesome-copilot/skills/review-and-refactor" in dep_keys, ( + f"Skill missing from lockfile after sequential install. Keys: {dep_keys}" + ) + + # deps tree should show both + tree = subprocess.run( + [apm_command, "deps", "tree"], + capture_output=True, text=True, cwd=str(temp_project), timeout=60, + ) + combined = tree.stdout + tree.stderr + assert "context-engineering" in combined, "Plugin missing from deps tree" + assert "review-and-refactor" in combined, "Skill missing from deps tree" + + # Uninstall plugin should clean up agent files + r3 = subprocess.run( + [apm_command, "uninstall", self.PLUGIN_REF], + capture_output=True, text=True, cwd=str(temp_project), timeout=60, + ) + assert r3.returncode == 0, f"Uninstall failed:\n{r3.stderr}" + combined = r3.stdout + r3.stderr + assert "agent" in combined.lower(), ( + f"Uninstall should report agent cleanup:\n{combined}" + ) + + # ---- Test 7: compile includes plugin primitives --------------------- + + def test_compile_includes_plugin_primitives(self, apm_command, temp_project): + """apm compile should include primitives from a normalized plugin.""" + # Install the plugin + r = subprocess.run( + [apm_command, "install", self.PLUGIN_REF, "--verbose"], + capture_output=True, text=True, cwd=str(temp_project), timeout=180, + ) + assert r.returncode == 0, f"Install failed:\n{r.stderr}" + + # Compile + result = subprocess.run( + [apm_command, "compile"], + capture_output=True, text=True, cwd=str(temp_project), timeout=60, + ) + assert result.returncode == 0, ( + f"Compile failed (rc={result.returncode}):\n{result.stderr}" + ) + + # AGENTS.md should exist (even if minimal — plugin primitives are in .apm/) + agents_md = temp_project / "AGENTS.md" + if agents_md.exists(): + content = agents_md.read_text() + # Should reference the plugin as a source + assert "context-engineering" in content.lower() or "awesome-copilot" in content.lower(), ( + f"AGENTS.md should reference the plugin source:\n{content[:500]}" + ) + + # ---- Test 8: prune removes orphaned plugin -------------------------- + + def test_prune_removes_orphaned_plugin(self, apm_command, temp_project): + """apm prune should remove a plugin no longer in apm.yml.""" + # Install the plugin + r = subprocess.run( + [apm_command, "install", self.PLUGIN_REF, "--verbose"], + capture_output=True, text=True, cwd=str(temp_project), timeout=180, + ) + assert r.returncode == 0, f"Install failed:\n{r.stderr}" + + pkg_path = temp_project / "apm_modules" / self.PLUGIN_REF + assert pkg_path.is_dir(), "Plugin must be installed before prune test" + + # Remove the plugin from apm.yml (simulate user edit) + apm_yml = temp_project / "apm.yml" + apm_yml.write_text( + "name: e2e-test-project\n" + "version: 1.0.0\n" + "description: E2E test project\n" + "dependencies:\n" + " apm: []\n" + ) + + # Prune should detect and remove the orphan + result = subprocess.run( + [apm_command, "prune"], + capture_output=True, text=True, cwd=str(temp_project), timeout=60, + ) + assert result.returncode == 0, ( + f"Prune failed (rc={result.returncode}):\n{result.stderr}" + ) + + combined = result.stdout + result.stderr + assert "orphan" in combined.lower() or "removed" in combined.lower(), ( + f"Prune should report orphan removal:\n{combined}" + ) + + # ---- Test 9: install counter reports plugin ------------------------- + + def test_install_counter_includes_plugin(self, apm_command, temp_project): + """apm install output should count plugin as an installed dependency.""" + result = subprocess.run( + [apm_command, "install", self.PLUGIN_REF], + capture_output=True, text=True, cwd=str(temp_project), timeout=180, + ) + assert result.returncode == 0, f"Install failed:\n{result.stderr}" + + combined = result.stdout + result.stderr + # Should NOT say "Installed 0" + assert "installed 0" not in combined.lower(), ( + f"Install counter should not be 0 after plugin install:\n{combined}" + ) + + # ---- Test 10: lockfile records package_type ------------------------- + + def test_lockfile_records_package_type(self, apm_command, temp_project): + """Lockfile should record package_type for plugin dependencies.""" + result = subprocess.run( + [apm_command, "install", self.PLUGIN_REF], + capture_output=True, text=True, cwd=str(temp_project), timeout=180, + ) + assert result.returncode == 0, f"Install failed:\n{result.stderr}" + + import yaml + + lockfile = yaml.safe_load((temp_project / "apm.lock").read_text()) + assert "dependencies" in lockfile, "Lockfile missing dependencies" + + plugin_entry = None + for dep in lockfile["dependencies"]: + key = dep.get("repo_url", "") + vpath = dep.get("virtual_path", "") + full = f"{key}/{vpath}" if vpath else key + if "context-engineering" in full: + plugin_entry = dep + break + + assert plugin_entry is not None, ( + f"Plugin not found in lockfile. Deps: {lockfile['dependencies']}" + ) + assert plugin_entry.get("package_type") == "marketplace_plugin", ( + f"Expected package_type 'marketplace_plugin', got: {plugin_entry.get('package_type')}" + ) + + # ---- Test 11: idempotent reinstall ---------------------------------- + + def test_idempotent_reinstall(self, apm_command, temp_project): + """Running apm install twice should be safe and produce identical results.""" + # First install + r1 = subprocess.run( + [apm_command, "install", self.PLUGIN_REF], + capture_output=True, text=True, cwd=str(temp_project), timeout=180, + ) + assert r1.returncode == 0, f"First install failed:\n{r1.stderr}" + + # Capture lockfile state + import yaml + + lock1 = yaml.safe_load((temp_project / "apm.lock").read_text()) + + # Second install (should use cache) + r2 = subprocess.run( + [apm_command, "install", self.PLUGIN_REF], + capture_output=True, text=True, cwd=str(temp_project), timeout=180, + ) + assert r2.returncode == 0, f"Second install failed:\n{r2.stderr}" + + # Lockfile should be identical + lock2 = yaml.safe_load((temp_project / "apm.lock").read_text()) + assert len(lock1["dependencies"]) == len(lock2["dependencies"]), ( + "Reinstall changed lockfile dependency count" + ) + + # Package should still be on disk + pkg_path = temp_project / "apm_modules" / self.PLUGIN_REF + assert pkg_path.is_dir(), "Plugin should still be present after reinstall" diff --git a/tests/test_apm_package_models.py b/tests/test_apm_package_models.py index c30734918..ae5d61da6 100644 --- a/tests/test_apm_package_models.py +++ b/tests/test_apm_package_models.py @@ -661,14 +661,12 @@ 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 that a directory without apm.yml/SKILL.md normalizes as a Claude plugin.""" + """Test that a directory without apm.yml/SKILL.md/plugin evidence is invalid.""" with tempfile.TemporaryDirectory() as tmpdir: result = validate_apm_package(Path(tmpdir)) - # 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 + # Empty directories without plugin.json or component dirs are not valid + assert not result.is_valid + assert result.package_type is None def test_validate_invalid_apm_yml(self): """Test validating directory with invalid apm.yml.""" @@ -938,13 +936,12 @@ 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 normalizes as Claude plugin.""" + """Test that a dir with no apm.yml, SKILL.md, hooks, or plugin evidence is invalid.""" with tempfile.TemporaryDirectory() as tmpdir: result = validate_apm_package(Path(tmpdir)) - # 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 + # Empty directories without plugin.json or component dirs are not valid + assert not result.is_valid + assert result.package_type is None class TestGitReferenceUtils: diff --git a/tests/unit/integration/test_agent_integrator.py b/tests/unit/integration/test_agent_integrator.py index fa0548a71..93f6a7f03 100644 --- a/tests/unit/integration/test_agent_integrator.py +++ b/tests/unit/integration/test_agent_integrator.py @@ -505,6 +505,29 @@ def test_find_agent_files_ignores_skill_files(self): assert "SKILL.md" not in found_names assert "skill.md" not in found_names + def test_find_agent_files_excludes_non_agent_md(self): + """Plain .md scan in .apm/agents/ excludes README, CHANGELOG, etc.""" + package_dir = self.project_root / "package" + apm_agents = package_dir / ".apm" / "agents" + apm_agents.mkdir(parents=True) + + (apm_agents / "planner.md").write_text("# Planner agent") + (apm_agents / "coder.md").write_text("# Coder agent") + (apm_agents / "README.md").write_text("# Docs") + (apm_agents / "CHANGELOG.md").write_text("# Changes") + (apm_agents / "LICENSE.md").write_text("MIT") + (apm_agents / "CONTRIBUTING.md").write_text("# Contributing") + + agents = self.integrator.find_agent_files(package_dir) + names = {a.name for a in agents} + + assert "planner.md" in names + assert "coder.md" in names + assert "README.md" not in names + assert "CHANGELOG.md" not in names + assert "LICENSE.md" not in names + assert "CONTRIBUTING.md" not in names + class TestAgentSuffixPattern: """Test clean naming pattern edge cases for agents.""" diff --git a/tests/unit/test_plugin_parser.py b/tests/unit/test_plugin_parser.py new file mode 100644 index 000000000..ed5357831 --- /dev/null +++ b/tests/unit/test_plugin_parser.py @@ -0,0 +1,516 @@ +"""Unit tests for plugin_parser.py and find_plugin_json helper.""" + +import json +import os +from pathlib import Path + +import pytest +import yaml + +from apm_cli.deps.plugin_parser import ( + _generate_apm_yml, + _map_plugin_artifacts, + normalize_plugin_directory, + parse_plugin_manifest, + synthesize_apm_yml_from_plugin, + validate_plugin_package, +) +from apm_cli.utils.helpers import find_plugin_json + + +class TestFindPluginJson: + def test_find_plugin_json_root(self, tmp_path): + pj = tmp_path / "plugin.json" + pj.write_text('{"name": "root-plugin"}') + + result = find_plugin_json(tmp_path) + assert result == pj + + def test_find_plugin_json_github_format(self, tmp_path): + gh_dir = tmp_path / ".github" / "plugin" + gh_dir.mkdir(parents=True) + pj = gh_dir / "plugin.json" + pj.write_text('{"name": "gh-plugin"}') + + result = find_plugin_json(tmp_path) + assert result == pj + + def test_find_plugin_json_claude_format(self, tmp_path): + claude_dir = tmp_path / ".claude-plugin" + claude_dir.mkdir() + pj = claude_dir / "plugin.json" + pj.write_text('{"name": "claude-plugin"}') + + result = find_plugin_json(tmp_path) + assert result == pj + + def test_find_plugin_json_priority_root_wins(self, tmp_path): + root_pj = tmp_path / "plugin.json" + root_pj.write_text('{"name": "root"}') + + gh_dir = tmp_path / ".github" / "plugin" + gh_dir.mkdir(parents=True) + (gh_dir / "plugin.json").write_text('{"name": "gh"}') + + result = find_plugin_json(tmp_path) + assert result == root_pj + + def test_find_plugin_json_not_found(self, tmp_path): + result = find_plugin_json(tmp_path) + assert result is None + + def test_find_plugin_json_ignores_deep_nested(self, tmp_path): + deep = tmp_path / "node_modules" / "some-pkg" + deep.mkdir(parents=True) + (deep / "plugin.json").write_text('{"name": "deep"}') + + result = find_plugin_json(tmp_path) + assert result is None + + +class TestParsePluginManifest: + def test_parse_valid_manifest(self, tmp_path): + pj = tmp_path / "plugin.json" + manifest = { + "name": "test-plugin", + "version": "1.2.3", + "description": "A test plugin", + "author": {"name": "Alice", "email": "a@b.c"}, + "license": "MIT", + "tags": ["test", "demo"], + "dependencies": {"dep-a": "^1.0.0"}, + } + pj.write_text(json.dumps(manifest)) + + result = parse_plugin_manifest(pj) + assert result["name"] == "test-plugin" + assert result["version"] == "1.2.3" + assert result["author"]["name"] == "Alice" + assert result["tags"] == ["test", "demo"] + + def test_parse_minimal_manifest(self, tmp_path): + pj = tmp_path / "plugin.json" + pj.write_text('{"name": "minimal"}') + + result = parse_plugin_manifest(pj) + assert result == {"name": "minimal"} + + def test_parse_missing_file(self, tmp_path): + with pytest.raises(FileNotFoundError): + parse_plugin_manifest(tmp_path / "nonexistent.json") + + def test_parse_invalid_json(self, tmp_path): + pj = tmp_path / "plugin.json" + pj.write_text("{ not valid json }") + + with pytest.raises(ValueError, match="Invalid JSON"): + parse_plugin_manifest(pj) + + +class TestMapPluginArtifacts: + def test_map_agents_directory(self, tmp_path): + plugin_dir = tmp_path / "plugin" + plugin_dir.mkdir() + agents = plugin_dir / "agents" + agents.mkdir() + (agents / "helper.agent.md").write_text("# Helper") + + apm_dir = plugin_dir / ".apm" + apm_dir.mkdir() + _map_plugin_artifacts(plugin_dir, apm_dir) + + assert (apm_dir / "agents" / "helper.agent.md").exists() + assert (apm_dir / "agents" / "helper.agent.md").read_text() == "# Helper" + + def test_map_skills_directory(self, tmp_path): + plugin_dir = tmp_path / "plugin" + plugin_dir.mkdir() + skills = plugin_dir / "skills" + skills.mkdir() + skill_dir = skills / "my-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("# Skill") + + apm_dir = plugin_dir / ".apm" + apm_dir.mkdir() + _map_plugin_artifacts(plugin_dir, apm_dir) + + assert (apm_dir / "skills" / "my-skill" / "SKILL.md").exists() + + def test_map_commands_to_prompts(self, tmp_path): + plugin_dir = tmp_path / "plugin" + plugin_dir.mkdir() + commands = plugin_dir / "commands" + commands.mkdir() + (commands / "run.md").write_text("# Run") + (commands / "already.prompt.md").write_text("# Already") + + apm_dir = plugin_dir / ".apm" + apm_dir.mkdir() + _map_plugin_artifacts(plugin_dir, apm_dir) + + prompts = apm_dir / "prompts" + assert prompts.exists() + # .md → .prompt.md rename + assert (prompts / "run.prompt.md").exists() + assert (prompts / "run.prompt.md").read_text() == "# Run" + # Already .prompt.md stays unchanged + assert (prompts / "already.prompt.md").exists() + + def test_map_hooks_directory(self, tmp_path): + plugin_dir = tmp_path / "plugin" + plugin_dir.mkdir() + hooks = plugin_dir / "hooks" + hooks.mkdir() + (hooks / "pre-install.sh").write_text("#!/bin/sh\necho hi") + + apm_dir = plugin_dir / ".apm" + apm_dir.mkdir() + _map_plugin_artifacts(plugin_dir, apm_dir) + + assert (apm_dir / "hooks" / "pre-install.sh").exists() + + def test_map_mcp_json_passthrough(self, tmp_path): + plugin_dir = tmp_path / "plugin" + plugin_dir.mkdir() + mcp_data = {"mcpServers": {"s": {"command": "node"}}} + (plugin_dir / ".mcp.json").write_text(json.dumps(mcp_data)) + + apm_dir = plugin_dir / ".apm" + apm_dir.mkdir() + _map_plugin_artifacts(plugin_dir, apm_dir) + + target = apm_dir / ".mcp.json" + assert target.exists() + assert json.loads(target.read_text()) == mcp_data + + def test_no_symlink_follow(self, tmp_path): + plugin_dir = tmp_path / "plugin" + plugin_dir.mkdir() + agents = plugin_dir / "agents" + agents.mkdir() + (agents / "real.md").write_text("# Real") + + # Create a symlink inside agents/ + external = tmp_path / "external" + external.mkdir() + (external / "secret.md").write_text("# Secret") + symlink_target = agents / "linked" + try: + symlink_target.symlink_to(external) + except OSError: + pytest.skip("Symlinks not supported on this platform") + + apm_dir = plugin_dir / ".apm" + apm_dir.mkdir() + _map_plugin_artifacts(plugin_dir, apm_dir) + + # Real file is copied + assert (apm_dir / "agents" / "real.md").exists() + # _ignore_symlinks callback causes copytree to skip symlinks entirely + copied_linked = apm_dir / "agents" / "linked" + assert not copied_linked.exists(), "Symlinked directory should be skipped entirely by _ignore_symlinks" + + # ---- Custom component paths from plugin.json ---- + + def test_custom_agents_path_string(self, tmp_path): + """Manifest agents field as a string redirects agent discovery.""" + plugin_dir = tmp_path / "plugin" + plugin_dir.mkdir() + custom = plugin_dir / "src" / "my-agents" + custom.mkdir(parents=True) + (custom / "bot.agent.md").write_text("# Bot") + + apm_dir = plugin_dir / ".apm" + apm_dir.mkdir() + _map_plugin_artifacts(plugin_dir, apm_dir, manifest={"agents": "src/my-agents"}) + + assert (apm_dir / "agents" / "bot.agent.md").exists() + + def test_custom_skills_path_array(self, tmp_path): + """Manifest skills array preserves each directory as named component.""" + plugin_dir = tmp_path / "plugin" + plugin_dir.mkdir() + s1 = plugin_dir / "skills" + s1.mkdir() + (s1 / "SKILL.md").write_text("# A") + s2 = plugin_dir / "extra-skills" + s2.mkdir() + (s2 / "SKILL.md").write_text("# B") + + apm_dir = plugin_dir / ".apm" + apm_dir.mkdir() + _map_plugin_artifacts( + plugin_dir, apm_dir, + manifest={"skills": ["skills/", "extra-skills/"]}, + ) + + # Each array entry becomes a named subdirectory + assert (apm_dir / "skills" / "skills" / "SKILL.md").read_text() == "# A" + assert (apm_dir / "skills" / "extra-skills" / "SKILL.md").read_text() == "# B" + + def test_custom_commands_path(self, tmp_path): + """Manifest commands field redirects command discovery.""" + plugin_dir = tmp_path / "plugin" + plugin_dir.mkdir() + cmds = plugin_dir / "my-cmds" + cmds.mkdir() + (cmds / "deploy.md").write_text("# Deploy") + + apm_dir = plugin_dir / ".apm" + apm_dir.mkdir() + _map_plugin_artifacts(plugin_dir, apm_dir, manifest={"commands": "my-cmds"}) + + assert (apm_dir / "prompts" / "deploy.prompt.md").exists() + + def test_hooks_file_path(self, tmp_path): + """Manifest hooks as a file path copies it to .apm/hooks/hooks.json.""" + plugin_dir = tmp_path / "plugin" + plugin_dir.mkdir() + hooks_data = {"hooks": {"PreToolUse": [{"matcher": "bash", "hooks": [{"type": "command", "command": "echo ok"}]}]}} + (plugin_dir / "my-hooks.json").write_text(json.dumps(hooks_data)) + + apm_dir = plugin_dir / ".apm" + apm_dir.mkdir() + _map_plugin_artifacts(plugin_dir, apm_dir, manifest={"hooks": "my-hooks.json"}) + + target = apm_dir / "hooks" / "hooks.json" + assert target.exists() + assert json.loads(target.read_text()) == hooks_data + + def test_hooks_inline_object(self, tmp_path): + """Manifest hooks as an inline object writes .apm/hooks/hooks.json.""" + plugin_dir = tmp_path / "plugin" + plugin_dir.mkdir() + hooks_obj = {"hooks": {"Stop": [{"matcher": "", "hooks": [{"type": "command", "command": "echo done"}]}]}} + + apm_dir = plugin_dir / ".apm" + apm_dir.mkdir() + _map_plugin_artifacts(plugin_dir, apm_dir, manifest={"hooks": hooks_obj}) + + target = apm_dir / "hooks" / "hooks.json" + assert target.exists() + assert json.loads(target.read_text()) == hooks_obj + + def test_hooks_directory_path(self, tmp_path): + """Manifest hooks as a custom directory path copies the directory.""" + plugin_dir = tmp_path / "plugin" + plugin_dir.mkdir() + custom_hooks = plugin_dir / "my-hooks" + custom_hooks.mkdir() + (custom_hooks / "hooks.json").write_text('{"hooks": {}}') + scripts = custom_hooks / "scripts" + scripts.mkdir() + (scripts / "lint.sh").write_text("#!/bin/sh\necho lint") + + apm_dir = plugin_dir / ".apm" + apm_dir.mkdir() + _map_plugin_artifacts(plugin_dir, apm_dir, manifest={"hooks": "my-hooks"}) + + assert (apm_dir / "hooks" / "hooks.json").exists() + assert (apm_dir / "hooks" / "scripts" / "lint.sh").exists() + + def test_nonexistent_custom_path_ignored(self, tmp_path): + """Custom paths that don't exist are silently ignored.""" + plugin_dir = tmp_path / "plugin" + plugin_dir.mkdir() + + apm_dir = plugin_dir / ".apm" + apm_dir.mkdir() + _map_plugin_artifacts( + plugin_dir, apm_dir, + manifest={"agents": "does-not-exist/", "skills": ["also-missing/"]}, + ) + + assert not (apm_dir / "agents").exists() + assert not (apm_dir / "skills").exists() + + # ---- Individual file paths (not just directories) ---- + + def test_agents_individual_file_paths(self, tmp_path): + """Manifest agents as individual file paths copies each file.""" + plugin_dir = tmp_path / "plugin" + plugin_dir.mkdir() + agents_dir = plugin_dir / "agents" + agents_dir.mkdir() + (agents_dir / "planner.md").write_text("# Planner") + (agents_dir / "coder.md").write_text("# Coder") + + apm_dir = plugin_dir / ".apm" + apm_dir.mkdir() + _map_plugin_artifacts( + plugin_dir, apm_dir, + manifest={"agents": ["./agents/planner.md", "./agents/coder.md"]}, + ) + + assert (apm_dir / "agents" / "planner.md").read_text() == "# Planner" + assert (apm_dir / "agents" / "coder.md").read_text() == "# Coder" + + def test_skills_individual_file_paths(self, tmp_path): + """Manifest skills as individual file paths copies each file.""" + plugin_dir = tmp_path / "plugin" + plugin_dir.mkdir() + skill = plugin_dir / "my-skill.md" + skill.write_text("# Skill") + + apm_dir = plugin_dir / ".apm" + apm_dir.mkdir() + _map_plugin_artifacts( + plugin_dir, apm_dir, + manifest={"skills": ["my-skill.md"]}, + ) + + assert (apm_dir / "skills" / "my-skill.md").read_text() == "# Skill" + + def test_commands_individual_file_paths(self, tmp_path): + """Manifest commands as individual file paths; .md normalized to .prompt.md.""" + plugin_dir = tmp_path / "plugin" + plugin_dir.mkdir() + (plugin_dir / "deploy.md").write_text("# Deploy") + + apm_dir = plugin_dir / ".apm" + apm_dir.mkdir() + _map_plugin_artifacts( + plugin_dir, apm_dir, + manifest={"commands": ["deploy.md"]}, + ) + + assert (apm_dir / "prompts" / "deploy.prompt.md").read_text() == "# Deploy" + + def test_mixed_files_and_dirs(self, tmp_path): + """Manifest mixing file and directory paths for same component.""" + plugin_dir = tmp_path / "plugin" + plugin_dir.mkdir() + agents_dir = plugin_dir / "agents" + agents_dir.mkdir() + (agents_dir / "a.md").write_text("# A") + (plugin_dir / "extra-agent.md").write_text("# Extra") + + apm_dir = plugin_dir / ".apm" + apm_dir.mkdir() + _map_plugin_artifacts( + plugin_dir, apm_dir, + manifest={"agents": ["./agents", "extra-agent.md"]}, + ) + + # Directory entry preserved as named subdir; file entry flat + assert (apm_dir / "agents" / "agents" / "a.md").read_text() == "# A" + assert (apm_dir / "agents" / "extra-agent.md").read_text() == "# Extra" + + +class TestGenerateApmYml: + def test_generate_full_metadata(self): + manifest = { + "name": "full-plugin", + "version": "2.0.0", + "description": "Full featured", + "author": "Bob", + "license": "Apache-2.0", + "repository": "https://github.com/org/repo", + "homepage": "https://example.com", + "tags": ["ai", "copilot"], + } + + yml_str = _generate_apm_yml(manifest) + parsed = yaml.safe_load(yml_str) + + assert parsed["name"] == "full-plugin" + assert parsed["version"] == "2.0.0" + assert parsed["description"] == "Full featured" + assert parsed["author"] == "Bob" + assert parsed["license"] == "Apache-2.0" + assert parsed["tags"] == ["ai", "copilot"] + assert parsed["type"] == "hybrid" + + def test_generate_minimal_metadata(self): + manifest = {"name": "minimal"} + + yml_str = _generate_apm_yml(manifest) + parsed = yaml.safe_load(yml_str) + + assert parsed["name"] == "minimal" + assert parsed["version"] == "0.0.0" + assert parsed["description"] == "" + assert parsed["type"] == "hybrid" + + def test_generate_author_as_dict(self): + manifest = { + "name": "dict-author", + "author": {"name": "Foo Bar", "email": "foo@bar.com"}, + } + + yml_str = _generate_apm_yml(manifest) + parsed = yaml.safe_load(yml_str) + + assert parsed["author"] == "Foo Bar" + + def test_generate_with_dependencies(self): + manifest = { + "name": "with-deps", + "dependencies": {"dep-a": "^1.0", "dep-b": "~2.0"}, + } + + yml_str = _generate_apm_yml(manifest) + parsed = yaml.safe_load(yml_str) + + assert parsed["dependencies"] == {"apm": {"dep-a": "^1.0", "dep-b": "~2.0"}} + + +class TestNormalizePluginDirectory: + def test_normalize_with_manifest(self, tmp_path): + plugin_dir = tmp_path / "my-plugin" + plugin_dir.mkdir() + pj = plugin_dir / "plugin.json" + pj.write_text(json.dumps({"name": "My Plugin", "version": "1.0.0"})) + (plugin_dir / "agents").mkdir() + (plugin_dir / "agents" / "bot.md").write_text("# Bot") + + result = normalize_plugin_directory(plugin_dir, pj) + + assert result == plugin_dir / "apm.yml" + assert result.exists() + parsed = yaml.safe_load(result.read_text()) + assert parsed["name"] == "My Plugin" + assert (plugin_dir / ".apm" / "agents" / "bot.md").exists() + + def test_normalize_without_manifest(self, tmp_path): + plugin_dir = tmp_path / "dir-name-plugin" + plugin_dir.mkdir() + (plugin_dir / "commands").mkdir() + (plugin_dir / "commands" / "go.md").write_text("# Go") + + result = normalize_plugin_directory(plugin_dir, plugin_json_path=None) + + assert result.exists() + parsed = yaml.safe_load(result.read_text()) + assert parsed["name"] == "dir-name-plugin" + assert (plugin_dir / ".apm" / "prompts" / "go.prompt.md").exists() + + +class TestValidatePluginPackage: + def test_validate_with_plugin_json(self, tmp_path): + plugin_dir = tmp_path / "valid" + plugin_dir.mkdir() + (plugin_dir / "plugin.json").write_text('{"name": "valid-plugin"}') + + assert validate_plugin_package(plugin_dir) is True + + def test_validate_with_component_dirs_only(self, tmp_path): + plugin_dir = tmp_path / "components" + plugin_dir.mkdir() + (plugin_dir / "agents").mkdir() + + assert validate_plugin_package(plugin_dir) is True + + def test_validate_empty_directory(self, tmp_path): + plugin_dir = tmp_path / "empty" + plugin_dir.mkdir() + + assert validate_plugin_package(plugin_dir) is False + + def test_validate_readme_only(self, tmp_path): + plugin_dir = tmp_path / "readme-only" + plugin_dir.mkdir() + (plugin_dir / "README.md").write_text("# Hello") + + assert validate_plugin_package(plugin_dir) is False