From cda9770c5d35ec41c63566b05b25e43abe9378e7 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Fri, 6 Feb 2026 08:24:44 +0100 Subject: [PATCH] fix: source integrity for all integrators + README restructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #73 Source integrity (npm-style approach): - Remove metadata injection from all integrators (skills, prompts, agents, commands) - Integrated files are now pure build artifacts — verbatim copies from apm_modules/ - apm.lock is the single source of truth (no more frontmatter metadata) - Nuke-and-regenerate sync for prompts/agents/commands - Name-based orphan detection for skills (like node_modules/) - Fix uninstall to re-integrate remaining packages after nuke Init cleanup: - Remove SKILL.md from apm init (not needed for consumer projects) README restructure: - Lead with dependency manifest value prop, not standards - Add transitive dependency story - Reframe as 'Not Just Skills' — all primitives, not just skills - Fix compile messaging (compiles instructions, not agents) - Add community sources (github/awesome-copilot, anthropics/courses) Tests: 796 unit tests passing (+5 new uninstall re-integration tests) --- README.md | 217 +++---- docs/cli-reference.md | 1 - docs/integrations.md | 18 +- docs/skills.md | 5 +- src/apm_cli/cli.py | 163 +++-- src/apm_cli/integration/agent_integrator.py | 345 +---------- src/apm_cli/integration/command_integrator.py | 214 +------ src/apm_cli/integration/prompt_integrator.py | 314 ++-------- src/apm_cli/integration/skill_integrator.py | 189 ++---- .../unit/integration/test_agent_integrator.py | 472 +++------------ .../integration/test_command_integrator.py | 394 +++++------- .../integration/test_prompt_integrator.py | 559 ++++-------------- .../unit/integration/test_skill_integrator.py | 247 +++----- ...test_sync_integration_url_normalization.py | 322 ++-------- tests/unit/test_init_command.py | 83 +-- tests/unit/test_orphan_detection.py | 214 ++----- tests/unit/test_uninstall_reintegration.py | 327 ++++++++++ uv.lock | 2 +- 18 files changed, 1162 insertions(+), 2924 deletions(-) create mode 100644 tests/unit/test_uninstall_reintegration.py diff --git a/README.md b/README.md index 2a3603add..3d4684e69 100644 --- a/README.md +++ b/README.md @@ -5,125 +5,118 @@ [![Downloads](https://img.shields.io/pypi/dm/apm-cli.svg)](https://pypi.org/project/apm-cli/) [![GitHub stars](https://img.shields.io/github/stars/danielmeppiel/apm.svg?style=social&label=Star)](https://github.com/danielmeppiel/apm/stargazers) -**npm for AI coding agents.** The package manager for [AGENTS.md](https://agents.md), [Agent Skills](https://agentskills.io), and MCP servers. +**The dependency manager for AI agents.** `apm.yml` declares the skills, prompts, instructions, and tools your project needs — so every developer gets the same agent setup. Packages can depend on packages, and APM resolves the full tree. + +Think `package.json`, `requirements.txt`, or `Cargo.toml` — but for AI agent configuration. GitHub Copilot · Cursor · Claude · Codex · Gemini -> 📐 **Built on open standards:** APM generates [AGENTS.md](https://agents.md) instructions, installs [Agent Skills](https://agentskills.io) natively, and manages [MCP](https://modelcontextprotocol.io) servers. +## Why APM -## Install +AI coding agents need context to be useful: what standards to follow, what prompts to use, what skills to leverage. Today this is manual — each developer installs things one by one, writes instructions from scratch, copies files around. None of it is portable. There's no manifest for it. -```bash -curl -sSL https://raw.githubusercontent.com/danielmeppiel/apm/main/install.sh | sh -``` +**APM fixes this.** You declare your project's agentic dependencies once, and every developer who clones your repo gets a fully configured agent setup in seconds. Packages can depend on other packages — APM resolves transitive dependencies automatically, just like npm or pip. -## Quick Start +## See It in Action + +```yaml +# apm.yml — ships with your project, like package.json +name: corporate-website +dependencies: + apm: + - danielmeppiel/form-builder # Skills: React Hook Form + Zod + - danielmeppiel/compliance-rules # Guardrails: GDPR, security audits + - danielmeppiel/design-guidelines # Standards: UI consistency, a11y +``` -**One package. Every AI agent. Native format for each.** +New developer joins the team: ```bash -# Install a skill — give your agent new capabilities -apm install danielmeppiel/form-builder +git clone your-org/corporate-website +cd corporate-website +apm install && apm compile +``` -# Install guardrails — keep your agent compliant -apm install danielmeppiel/compliance-rules +**That's it.** Copilot, Claude, Cursor — every agent is configured with the right skills, prompts, and coding standards. No wiki. No "ask Sarah which skills to install." It just works. -# Compile for your AI tools -apm compile -``` +→ [View the full example project](https://github.com/danielmeppiel/corporate-website) -**Done.** Type `/gdpr-assessment` or `/code-review` in Copilot or Claude. It just works. +## Not Just Skills -## What APM Does +Skill registries install skills. APM manages **every primitive** your AI agents need: -``` -┌─────────────────────────────────────────────────────────────────┐ -│ APM Packages (from GitHub, Azure DevOps) │ -│ ├── Instructions → Coding standards, guardrails (AGENTS.md) │ -│ ├── Skills → AI capabilities, workflows (agentskills.io) │ -│ ├── Prompts → Reusable commands and templates │ -│ └── MCP Servers → Tool integrations │ -└─────────────────────────────────────────────────────────────────┘ - │ - apm install && apm compile - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ Universal Output (auto-detected from .github/ and .claude/) │ -│ ├── AGENTS.md → Instructions for Copilot, Cursor, Codex │ -│ ├── CLAUDE.md → Instructions for Claude Code │ -│ ├── .github/ → VSCode native prompts & agents │ -│ └── .claude/ → Claude commands & skills │ -└─────────────────────────────────────────────────────────────────┘ -``` +| Primitive | What it does | Example | +|-----------|-------------|---------| +| **Instructions** | Coding standards, guardrails | "Use type hints in all Python files" | +| **Skills** | AI capabilities, workflows | Form builder, code reviewer | +| **Prompts** | Reusable slash commands | `/security-audit`, `/design-review` | +| **Agents** | Specialized personas | Accessibility auditor, API designer | +| **MCP Servers** | Tool integrations | Database access, API connectors | -**One package. Every AI agent. Native format for each.** +All declared in one manifest. All installed with one command — including transitive dependencies: -## Real Example: corporate-website +**`apm install`** → integrates prompts, agents, and skills into `.github/` and `.claude/` +**`apm compile`** → compiles instructions into `AGENTS.md` (Copilot, Cursor, Codex) and `CLAUDE.md` (Claude) -A production project using APM with skills and layered guardrails: +## Get Started -```yaml -# apm.yml -name: corporate-website -dependencies: - apm: - - danielmeppiel/form-builder # Build forms with React Hook Form + Zod - - danielmeppiel/compliance-rules # GDPR, security - - danielmeppiel/design-guidelines # UI standards +**1. Install APM** + +```bash +curl -sSL https://raw.githubusercontent.com/danielmeppiel/apm/main/install.sh | sh ``` +
+Homebrew or pip + ```bash -apm install && apm compile +brew tap danielmeppiel/apm-cli && brew install apm-cli +# or +pip install apm-cli ``` +
-→ [View the full example](https://github.com/danielmeppiel/corporate-website) +**2. Add packages to your project** -## Commands +```bash +apm install danielmeppiel/compliance-rules +``` -| Command | What it does | -|---------|--------------| -| `apm install ` | Add package to project | -| `apm compile` | Generate agent context files | -| `apm init` | Create new APM project | -| `apm run ` | Execute a workflow | -| `apm deps list` | Show installed packages | +> No `apm.yml` yet? APM creates one automatically on first install. -## Install From Anywhere +**3. Compile your instructions** ```bash -# For packages hosted on GitHub -apm install owner/repo +apm compile +``` -# Paths or Single file are also OK (Virtual Package) -apm install github/awesome-copilot/prompts/code-review.prompt.md +**Done.** Your instructions are compiled into AGENTS.md and CLAUDE.md — open your project in VS Code or Claude and your agents are ready. -# For packages in GitHub Enterprise with Data Residency -apm install ghe.company.com/owner/repo +## Install From Anywhere -# For packages Azure DevOps -apm install dev.azure.com/org/project/repo +```bash +apm install owner/repo # GitHub +apm install github/awesome-copilot/prompts/code-review.prompt.md # Single file +apm install ghe.company.com/owner/repo # GitHub Enterprise +apm install dev.azure.com/org/project/repo # Azure DevOps ``` -## Create Your Own Package +## Create & Share Packages ```bash apm init my-standards && cd my-standards ``` -This creates: - ``` my-standards/ ├── apm.yml # Package manifest -├── SKILL.md # Package meta-guide for AI discovery └── .apm/ ├── instructions/ # Guardrails (.instructions.md) - ├── prompts/ # Workflows (.prompt.md) + ├── prompts/ # Slash commands (.prompt.md) └── agents/ # Personas (.agent.md) ``` -Example guardrail: +Add a guardrail and publish: ```bash cat > .apm/instructions/python.instructions.md << 'EOF' @@ -135,42 +128,28 @@ applyTo: "**/*.py" - Follow PEP 8 style guidelines EOF -# Push and share git add . && git commit -m "Initial standards" && git push ``` -Anyone can now run: `apm install you/my-standards` - -## Installation Options - -```bash -# Quick install (recommended) -curl -sSL https://raw.githubusercontent.com/danielmeppiel/apm/main/install.sh | sh - -# Homebrew -brew tap danielmeppiel/apm-cli && brew install apm-cli - -# pip -pip install apm-cli -``` - -## Target Specific Agents +Anyone can now `apm install you/my-standards`. -```bash -apm compile # Auto-detects from .github/ and .claude/ folders -apm compile --target vscode # AGENTS.md + .github/ only -apm compile --target claude # CLAUDE.md + .claude/ only -apm compile --target all # Force all formats -``` +## All Commands -> **Note:** `apm compile` generates instruction files (AGENTS.md, CLAUDE.md). Prompts, agents, and skills are integrated by `apm install` into `.github/` and `.claude/` folders. +| Command | What it does | +|---------|--------------| +| `apm install ` | Add a package and integrate its primitives | +| `apm compile` | Compile instructions into AGENTS.md / CLAUDE.md | +| `apm init [name]` | Scaffold a new APM project or package | +| `apm run ` | Execute a prompt workflow via AI runtime | +| `apm deps list` | Show installed packages and versions | +| `apm compile --target` | Target a specific agent (`vscode`, `claude`, `all`) | -## Advanced Configuration +## Configuration -For private packages, Azure DevOps, or running prompts via AI runtimes: +For private repos or Azure DevOps, set a token: -| Token | Purpose | -|-------|---------| +| Token | When you need it | +|-------|-----------------| | `GITHUB_APM_PAT` | Private GitHub packages | | `ADO_APM_PAT` | Azure DevOps packages | | `GITHUB_COPILOT_PAT` | Running prompts via `apm run` | @@ -181,44 +160,30 @@ For private packages, Azure DevOps, or running prompts via AI runtimes: ## Community Packages -[![Install with APM](https://img.shields.io/badge/📦_Install_with-APM-blue?style=flat-square)](https://github.com/danielmeppiel/apm#community-packages) +APM installs from any GitHub or Azure DevOps repo — no special packaging required. Point at a prompt file, a skill, or a full package. These are some curated packages to get you started: | Package | What you get | |---------|-------------| -| [danielmeppiel/compliance-rules](https://github.com/danielmeppiel/compliance-rules) | `/gdpr-assessment`, `/security-audit` + compliance rules | +| [danielmeppiel/compliance-rules](https://github.com/danielmeppiel/compliance-rules) | `/gdpr-assessment`, `/security-audit` + compliance guardrails | | [danielmeppiel/design-guidelines](https://github.com/danielmeppiel/design-guidelines) | `/accessibility-audit`, `/design-review` + UI standards | | [DevExpGbb/platform-mode](https://github.com/DevExpGbb/platform-mode) | Platform engineering prompts & agents | +| [github/awesome-copilot](https://github.com/github/awesome-copilot) | Community prompts, agents & instructions for Copilot | +| [anthropics/courses](https://github.com/anthropics/courses) | Anthropic's official skills & prompt library | | [Add yours →](https://github.com/danielmeppiel/apm/discussions/new) | | --- ## Documentation -### Getting Started -| Guide | Description | -|-------|-------------| -| [Quick Start](docs/getting-started.md) | Complete setup, tokens, first project | -| [Core Concepts](docs/concepts.md) | How APM works, the primitives model | -| [Examples](docs/examples.md) | Real-world patterns and use cases | - -### Reference -| Guide | Description | -|-------|-------------| -| [CLI Reference](docs/cli-reference.md) | All commands and options | -| [Compilation Engine](docs/compilation.md) | Context optimization algorithm | -| [Skills](docs/skills.md) | Native [agentskills.io](https://agentskills.io) support | -| [Integrations](docs/integrations.md) | VSCode, Spec-kit, MCP servers | - -### Advanced -| Guide | Description | -|-------|-------------| -| [Dependencies](docs/dependencies.md) | Package management deep-dive | -| [Primitives](docs/primitives.md) | Building advanced workflows | -| [Contributing](CONTRIBUTING.md) | Join the ecosystem | +| | | +|---|---| +| **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) | +| **Advanced** | [Dependencies](docs/dependencies.md) · [Primitives](docs/primitives.md) · [Contributing](CONTRIBUTING.md) | --- -**Open Standards:** [AGENTS.md](https://agents.md) · [Agent Skills](https://agentskills.io) · [MCP](https://modelcontextprotocol.io) +**Built on open standards:** [AGENTS.md](https://agents.md) · [Agent Skills](https://agentskills.io) · [MCP](https://modelcontextprotocol.io) -**Learn AI-Native Development** → [Awesome AI Native](https://danielmeppiel.github.io/awesome-ai-native) +**Learn AI-Native Development** → [Awesome AI Native](https://danielmeppiel.github.io/awesome-ai-native) A practical learning path for AI-Native Development, leveraging APM along the way. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index d031f97cd..3f7d31c4c 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -97,7 +97,6 @@ apm init my-project --yes **Creates:** - `apm.yml` - Minimal project configuration with empty dependencies and scripts sections -- `SKILL.md` - Package meta-guide for AI discovery (describes what the package does) **Auto-detected fields:** - `name` - From project directory name diff --git a/docs/integrations.md b/docs/integrations.md index a4a1deb04..5f10342a8 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -148,19 +148,18 @@ APM automatically integrates prompts and agents from installed packages into VSC apm install danielmeppiel/design-guidelines # Prompts are automatically integrated to: -# .github/prompts/*-apm.prompt.md (with package metadata header) +# .github/prompts/*-apm.prompt.md (verbatim copy with -apm suffix) # Agents are automatically integrated to: -# .github/agents/*-apm.agent.md (with package metadata header) +# .github/agents/*-apm.agent.md (verbatim copy) ``` **How Auto-Integration Works**: - **Zero-Config**: Always enabled, works automatically with no configuration needed - **Auto-Cleanup**: Removes integrated prompts when you uninstall packages -- **Smart Updates**: Tracks package version/commit; updates only when package changes -- **Metadata Headers**: Integrated prompts include source, version, and commit information +- **Always Overwrite**: Prompt and agent files are always copied fresh — no version comparison - **GitIgnore Protection**: Automatically adds pattern to `.gitignore` for integrated prompts -- **User-Safe**: Preserves any custom `*-apm.prompt.md` files without APM metadata headers +- **Link Resolution**: Context links are resolved during integration **Integration Flow**: 1. Run `apm install` to fetch APM packages @@ -168,10 +167,9 @@ apm install danielmeppiel/design-guidelines 3. Discovers `.prompt.md` and `.agent.md` files in each package 4. Copies prompts to `.github/prompts/` with `-apm` suffix (e.g., `accessibility-audit-apm.prompt.md`) 5. Copies agents to `.github/agents/` with `-apm` suffix (e.g., `security-apm.agent.md`) -6. Adds metadata headers for version tracking -7. Updates `.gitignore` to exclude integrated prompts and agents -8. VSCode automatically loads all prompts and agents for your coding agents -9. Run `apm uninstall` to automatically remove integrated prompts and agents +6. Updates `.gitignore` to exclude integrated prompts and agents +7. VSCode automatically loads all prompts and agents for your coding agents +8. Run `apm uninstall` to automatically remove integrated prompts and agents **Intent-First Discovery**: The `-apm` suffix pattern enables natural autocomplete in VSCode: @@ -244,7 +242,7 @@ apm install danielmeppiel/design-guidelines **How it works:** 1. `apm install` detects `.prompt.md` files in the package 2. Converts each to Claude command format in `.claude/commands/` -3. Adds `-apm` suffix and metadata header for tracking +3. Adds `-apm` suffix for tracking 4. Updates `.gitignore` to exclude generated commands 5. `apm uninstall` automatically removes the package's commands diff --git a/docs/skills.md b/docs/skills.md index fd0ecae28..4f7786b1d 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -141,7 +141,7 @@ my-skill/ ### Quick Start with apm init -`apm init` automatically creates a SKILL.md at root: +`apm init` creates a minimal project: ```bash apm init my-skill && cd my-skill @@ -151,10 +151,11 @@ This creates: ``` my-skill/ ├── apm.yml # Package manifest -├── SKILL.md # Package meta-guide (edit this!) └── .apm/ # Primitives folder ``` +Add a `SKILL.md` at root to make it a publishable skill (see below). + ### Option 1: Standalone Skill Create a repo with just `SKILL.md`: diff --git a/src/apm_cli/cli.py b/src/apm_cli/cli.py index 2e60cadff..d69493371 100644 --- a/src/apm_cli/cli.py +++ b/src/apm_cli/cli.py @@ -355,16 +355,12 @@ def init(ctx, project_name, yes): if console: files_data = [ ("✨", "apm.yml", "Project configuration"), - ("📖", "SKILL.md", "Package meta-guide for AI discovery"), ] table = _create_files_table(files_data, title="Created Files") console.print(table) except (ImportError, NameError): _rich_info("Created:") _rich_echo(" ✨ apm.yml - Project configuration", style="muted") - _rich_echo( - " 📖 SKILL.md - Package meta-guide for AI discovery", style="muted" - ) _rich_blank_line() @@ -1157,70 +1153,89 @@ def uninstall(ctx, packages, dry_run): else: _rich_warning(f"Package {package} not found in apm_modules/") - # Sync prompt integration to remove orphaned prompts + # Sync integrations: nuke all -apm files and re-integrate from remaining packages prompts_cleaned = 0 prompts_failed = 0 - if Path(".github/prompts").exists(): - try: - from apm_cli.models.apm_package import APMPackage - from apm_cli.integration.prompt_integrator import PromptIntegrator - - apm_package = APMPackage.from_apm_yml(Path("apm.yml")) - integrator = PromptIntegrator() - cleanup_result = integrator.sync_integration(apm_package, Path(".")) - prompts_cleaned = cleanup_result.get("files_removed", 0) - prompts_failed = cleanup_result.get("errors", 0) - except Exception as e: - prompts_failed += 1 - - # Sync agent integration to remove orphaned agents agents_cleaned = 0 agents_failed = 0 - if Path(".github/agents").exists(): - try: - from apm_cli.models.apm_package import APMPackage - from apm_cli.integration.agent_integrator import AgentIntegrator - - apm_package = APMPackage.from_apm_yml(Path("apm.yml")) - integrator = AgentIntegrator() - cleanup_result = integrator.sync_integration(apm_package, Path(".")) - agents_cleaned = cleanup_result.get("files_removed", 0) - agents_failed = cleanup_result.get("errors", 0) - except Exception as e: - agents_failed += 1 - - # Sync skill integration to remove orphaned skills - # T12: Check both .github/skills/ and .claude/skills/ locations + commands_cleaned = 0 + commands_failed = 0 skills_cleaned = 0 skills_failed = 0 - if Path(".github/skills").exists() or Path(".claude/skills").exists(): - try: - from apm_cli.models.apm_package import APMPackage - from apm_cli.integration.skill_integrator import SkillIntegrator - apm_package = APMPackage.from_apm_yml(Path("apm.yml")) - integrator = SkillIntegrator() - cleanup_result = integrator.sync_integration(apm_package, Path(".")) - skills_cleaned = cleanup_result.get("files_removed", 0) - skills_failed = cleanup_result.get("errors", 0) - except Exception as e: - skills_failed += 1 + try: + from apm_cli.models.apm_package import APMPackage, PackageInfo, PackageType, validate_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 + from apm_cli.integration.command_integrator import CommandIntegrator - # Sync command integration to remove orphaned Claude commands - commands_cleaned = 0 - commands_failed = 0 - if Path(".claude/commands").exists(): - try: - from apm_cli.models.apm_package import APMPackage - from apm_cli.integration.command_integrator import CommandIntegrator + apm_package = APMPackage.from_apm_yml(Path("apm.yml")) + project_root = Path(".") + + # Phase 1: Nuke all -apm integrated files + if Path(".github/prompts").exists(): + integrator = PromptIntegrator() + result = integrator.sync_integration(apm_package, project_root) + prompts_cleaned = result.get("files_removed", 0) - apm_package = APMPackage.from_apm_yml(Path("apm.yml")) + if Path(".github/agents").exists(): + integrator = AgentIntegrator() + result = integrator.sync_integration(apm_package, project_root) + agents_cleaned = result.get("files_removed", 0) + + if Path(".github/skills").exists() or Path(".claude/skills").exists(): + integrator = SkillIntegrator() + result = integrator.sync_integration(apm_package, project_root) + skills_cleaned = result.get("files_removed", 0) + + if Path(".claude/commands").exists(): integrator = CommandIntegrator() - cleanup_result = integrator.sync_integration(apm_package, Path(".")) - commands_cleaned = cleanup_result.get("files_removed", 0) - commands_failed = cleanup_result.get("errors", 0) - except Exception as e: - commands_failed += 1 + result = integrator.sync_integration(apm_package, project_root) + commands_cleaned = result.get("files_removed", 0) + + # Phase 2: Re-integrate from remaining installed packages in apm_modules/ + prompt_integrator = PromptIntegrator() + agent_integrator = AgentIntegrator() + skill_integrator = SkillIntegrator() + command_integrator = CommandIntegrator() + + for dep in apm_package.get_apm_dependencies(): + dep_ref = dep if hasattr(dep, 'repo_url') else None + if not dep_ref: + continue + # Build install path + install_path = Path("apm_modules") / dep_ref.repo_url + if dep_ref.is_virtual and dep_ref.virtual_path: + install_path = Path("apm_modules") / dep_ref.repo_url / dep_ref.virtual_path + if not install_path.exists(): + continue + + # Build minimal PackageInfo for re-integration + result = validate_package(install_path) + pkg = result.package if result and result.package else None + if not pkg: + continue + pkg_info = PackageInfo( + package=pkg, + install_path=install_path, + dependency_ref=dep_ref, + package_type=result.package_type if result else None, + ) + + try: + if prompt_integrator.should_integrate(project_root): + prompt_integrator.integrate_package_prompts(pkg_info, project_root) + if agent_integrator.should_integrate(project_root): + agent_integrator.integrate_package_agents(pkg_info, project_root) + skill_integrator.integrate_package_skill(pkg_info, project_root) + if command_integrator.should_integrate(project_root): + command_integrator.integrate_package_commands(pkg_info, project_root) + except Exception: + pass # Best effort re-integration + + except Exception as e: + prompts_failed += 1 # Show cleanup feedback if prompts_cleaned > 0: @@ -4141,7 +4156,7 @@ def _get_default_config(project_name): def _create_minimal_apm_yml(config): - """Create minimal apm.yml file and SKILL.md with auto-detected metadata.""" + """Create minimal apm.yml file with auto-detected metadata.""" yaml = _lazy_yaml() # Create minimal apm.yml structure @@ -4158,36 +4173,6 @@ def _create_minimal_apm_yml(config): with open("apm.yml", "w") as f: yaml.safe_dump(apm_yml_data, f, default_flow_style=False, sort_keys=False) - # Create SKILL.md (package meta-guide for AI discovery) - skill_content = f"""--- -name: {config["name"]} -description: {config["description"]} ---- - -# {config["name"]} - -{config["description"]} - -## What This Package Does - -Describe what this package provides and how AI agents should use it. - -## Getting Started - -```bash -apm install your-org/{config["name"]} -apm compile -``` - -## Available Primitives - -- **Instructions**: Guardrails and standards in `.apm/instructions/` -- **Prompts**: Executable workflows in `.apm/prompts/` -- **Agents**: Specialized personas in `.apm/agents/` -""" - with open("SKILL.md", "w") as f: - f.write(skill_content) - def main(): """Main entry point for the CLI.""" diff --git a/src/apm_cli/integration/agent_integrator.py b/src/apm_cli/integration/agent_integrator.py index 7ef1ff92f..a622a7fc5 100644 --- a/src/apm_cli/integration/agent_integrator.py +++ b/src/apm_cli/integration/agent_integrator.py @@ -8,11 +8,8 @@ from pathlib import Path from typing import List, Dict from dataclasses import dataclass -from datetime import datetime -import hashlib import re -from .utils import normalize_repo_url from apm_cli.compilation.link_resolver import UnifiedLinkResolver from apm_cli.primitives.discovery import discover_primitives @@ -92,112 +89,6 @@ def find_agent_files(self, package_path: Path) -> List[Path]: # - This preserves the native skill format and avoids semantic confusion # - See skill-strategy.md for the full architectural rationale - def _parse_header_metadata(self, file_path: Path) -> dict: - """Parse APM metadata from YAML frontmatter in an integrated agent file. - - Args: - file_path: Path to the integrated agent file - - Returns: - dict: Metadata extracted from frontmatter (version, commit, source, etc.) - Empty dict if no valid frontmatter found or parsing fails - """ - try: - import frontmatter - - post = frontmatter.load(file_path) - - # Extract APM metadata from nested 'apm' key (new format) - apm_data = post.metadata.get('apm', {}) - if apm_data: - metadata = { - 'Version': apm_data.get('version', ''), - 'Commit': apm_data.get('commit', ''), - 'Source': f"{apm_data.get('source', '')} ({apm_data.get('source_repo', '')})", - 'SourceDependency': apm_data.get('source_dependency', ''), # Full dependency string - 'Original': apm_data.get('original_path', ''), - 'Installed': apm_data.get('installed_at', ''), - 'ContentHash': apm_data.get('content_hash', ''), - 'SourceType': apm_data.get('source_type', '') # Track if from skill - } - return metadata - - # Fallback: Check for old flat format (backwards compatibility) - if 'apm_version' in post.metadata: - metadata = { - 'Version': post.metadata.get('apm_version', ''), - 'Commit': post.metadata.get('apm_commit', ''), - 'Source': f"{post.metadata.get('apm_source', '')} ({post.metadata.get('apm_source_repo', '')})", - 'Original': post.metadata.get('apm_original_path', ''), - 'Installed': post.metadata.get('apm_installed_at', ''), - 'ContentHash': post.metadata.get('apm_content_hash', '') - } - return metadata - - return {} # Not an APM-integrated file - except Exception: - # If any error occurs during parsing, return empty dict - return {} - - def _calculate_content_hash(self, file_path: Path) -> str: - """Calculate SHA256 hash of file content (excluding frontmatter). - - Args: - file_path: Path to the file - - Returns: - str: Hexadecimal hash of the content - """ - try: - import frontmatter - post = frontmatter.load(file_path) - # Hash only the content, not the frontmatter - return hashlib.sha256(post.content.encode()).hexdigest() - except Exception: - return "" - - def _should_update_agent(self, existing_header: dict, package_info, existing_file: Path = None) -> tuple[bool, bool]: - """Determine if an existing agent file should be updated. - - Args: - existing_header: Metadata from existing file's header - package_info: PackageInfo object with new package metadata - existing_file: Path to existing file for content hash verification - - Returns: - tuple[bool, bool]: (should_update, was_modified) - - should_update: True if file should be updated - - was_modified: True if content was modified by user - """ - # If no valid header exists, update the file - if not existing_header: - return (True, False) - - # Get new version and commit - new_version = package_info.package.version - new_commit = ( - package_info.resolved_reference.resolved_commit - if package_info.resolved_reference - else "unknown" - ) - - # Get existing version and commit from header - existing_version = existing_header.get('Version', '') - existing_commit = existing_header.get('Commit', '') - - # Check for content modifications if we have the file path - was_modified = False - if existing_file and existing_file.exists(): - stored_hash = existing_header.get('ContentHash', '') - if stored_hash: - current_hash = self._calculate_content_hash(existing_file) - was_modified = (current_hash != stored_hash and current_hash != "") - - # Update if version or commit has changed - should_update = (existing_version != new_version or existing_commit != new_commit) - return (should_update, was_modified) - - def get_target_filename(self, source_file: Path, package_name: str) -> str: """Generate target filename with -apm suffix (intent-first naming). @@ -229,82 +120,40 @@ def get_target_filename(self, source_file: Path, package_name: str) -> str: return f"{stem}-apm{extension}" - def copy_agent_with_metadata(self, source: Path, target: Path, package_info, original_path: Path) -> int: - """Copy agent file with APM metadata embedded in frontmatter. - Resolves context links before writing. + def copy_agent(self, source: Path, target: Path) -> int: + """Copy agent file verbatim, resolving context links. Args: source: Source file path target: Target file path - package_info: PackageInfo object with package metadata - original_path: Original path to the agent file Returns: int: Number of links resolved """ - import frontmatter - - # Read and parse source file with frontmatter - post = frontmatter.load(source) + content = source.read_text(encoding='utf-8') # Resolve context links in content links_resolved = 0 if self.link_resolver: - original_content = post.content - resolved_content = self.link_resolver.resolve_links_for_installation( - content=post.content, + original_content = content + content = self.link_resolver.resolve_links_for_installation( + content=content, source_file=source, target_file=target ) - post.content = resolved_content - # Count how many links changed by comparing actual link content - if resolved_content != original_content: - import re - # Extract all links from both versions + if content != original_content: link_pattern = re.compile(r'\]\(([^)]+)\)') original_links = set(link_pattern.findall(original_content)) - resolved_links = set(link_pattern.findall(resolved_content)) - # Count links that were changed + resolved_links = set(link_pattern.findall(content)) links_resolved = len(original_links - resolved_links) - # Calculate content hash for modification detection (after link resolution) - content_hash = hashlib.sha256(post.content.encode()).hexdigest() - - # Add APM metadata to frontmatter (nested under 'apm' key for clarity) - post.metadata['apm'] = { - 'source': package_info.package.name, - 'source_repo': package_info.package.source or "unknown", - 'source_dependency': package_info.get_canonical_dependency_string(), # Full dependency string for orphan detection - 'version': package_info.package.version, - 'commit': ( - package_info.resolved_reference.resolved_commit - if package_info.resolved_reference - else "unknown" - ), - 'original_path': ( - str(original_path.relative_to(package_info.install_path)) - if original_path.is_relative_to(package_info.install_path) - else original_path.name - ), - 'installed_at': package_info.installed_at or datetime.now().isoformat(), - 'content_hash': content_hash - } - - # Write to target with modified frontmatter - with open(target, 'w', encoding='utf-8') as f: - f.write(frontmatter.dumps(post)) - + target.write_text(content, encoding='utf-8') return links_resolved def integrate_package_agents(self, package_info, project_root: Path) -> IntegrationResult: """Integrate all agents from a package into .github/agents/. - Implements smart update logic: - - First install: Copy with header and -apm suffix - - Subsequent installs: - - Compare version/commit with existing file - - Update if different (re-copy with new header) - - Skip if unchanged (preserve file timestamps) + Always overwrites existing files (no version comparison). Resolves context links during integration. Note: SKILL.md files are NOT transformed to .agent.md files. @@ -345,192 +194,54 @@ def integrate_package_agents(self, package_info, project_root: Path) -> Integrat agents_dir = project_root / ".github" / "agents" agents_dir.mkdir(parents=True, exist_ok=True) - # Process each agent file + # Process each agent file — always overwrite files_integrated = 0 - files_updated = 0 - files_skipped = 0 target_paths = [] total_links_resolved = 0 for source_file in agent_files: - # Generate target filename target_filename = self.get_target_filename(source_file, package_info.package.name) target_path = agents_dir / target_filename - # Check if target already exists - if target_path.exists(): - # Parse existing file's frontmatter metadata - existing_header = self._parse_header_metadata(target_path) - - # Check if update is needed and if content was modified - should_update, was_modified = self._should_update_agent( - existing_header, package_info, target_path - ) - - if should_update: - # Warn if user modified the content - if was_modified: - from apm_cli.cli import _rich_warning - _rich_warning( - f"⚠ Restoring modified file: {target_path.name} " - f"(your changes will be overwritten)" - ) - # Version or commit changed - update the file - links_resolved = self.copy_agent_with_metadata(source_file, target_path, package_info, source_file) - total_links_resolved += links_resolved - files_updated += 1 - target_paths.append(target_path) - else: - # Unchanged version/commit - skip to preserve file timestamp - files_skipped += 1 - else: - # New file - integrate it - links_resolved = self.copy_agent_with_metadata(source_file, target_path, package_info, source_file) - total_links_resolved += links_resolved - files_integrated += 1 - target_paths.append(target_path) + links_resolved = self.copy_agent(source_file, target_path) + total_links_resolved += links_resolved + files_integrated += 1 + target_paths.append(target_path) return IntegrationResult( files_integrated=files_integrated, - files_updated=files_updated, - files_skipped=files_skipped, + files_updated=0, + files_skipped=0, target_paths=target_paths, gitignore_updated=False, links_resolved=total_links_resolved ) def sync_integration(self, apm_package, project_root: Path) -> Dict[str, int]: - """Sync .github/agents/ with currently installed packages. - - - Removes agents from uninstalled packages (orphans) - - Updates agents from updated packages - - Adds agents from new packages - - Idempotent: safe to call anytime. Reuses existing smart update logic. + """Remove all APM-managed agent files for clean regeneration. Args: - apm_package: APMPackage with current dependencies + apm_package: APMPackage with current dependencies (unused, kept for API compat) project_root: Root directory of the project Returns: Dict with 'files_removed' and 'errors' counts """ + stats = {'files_removed': 0, 'errors': 0} + agents_dir = project_root / ".github" / "agents" if not agents_dir.exists(): - return {'files_removed': 0, 'errors': 0} - - # Build set of canonical dependency strings for comparison - # For virtual packages: owner/repo/path/to/file or owner/repo/collections/name - # For regular packages: owner/repo - installed_deps = set() - installed_repo_urls = set() # Fallback for old metadata format - for dep in apm_package.get_apm_dependencies(): - installed_deps.add(dep.get_canonical_dependency_string()) - installed_repo_urls.add(dep.repo_url) - - # Track cleanup statistics - files_removed = 0 - errors = 0 - - # Remove orphaned agents (from uninstalled packages) - for agent_file in agents_dir.glob("*-apm.agent.md"): - metadata = self._parse_header_metadata(agent_file) - - # Skip files without valid metadata - they might be user's custom files - if not metadata: - continue - - # Try new format first: source_dependency contains full dependency string - source_dependency = metadata.get('SourceDependency', '') - - if source_dependency and source_dependency != 'unknown': - # New format: compare full dependency strings - normalized_dep = normalize_repo_url(source_dependency) - package_match = any( - dep == normalized_dep or dep == source_dependency - for dep in installed_deps - ) - else: - # Fallback: old format using repo URL from Source field - source = metadata.get('Source', '') - if not source: - continue - - # Extract package repo URL from source - # Format: "package-name (owner/repo)" or "package-name (host.com/owner/repo)" - package_repo_url = None - if '(' in source and ')' in source: - package_repo_url = source.split('(')[1].split(')')[0].strip() - - if not package_repo_url: - continue - - # Normalize the repo URL for comparison - normalized_package_url = normalize_repo_url(package_repo_url) - - # Check if source package is still installed (using repo_url for backwards compat) - package_match = any( - pkg == normalized_package_url or - (pkg + '.git') == normalized_package_url or - pkg == package_repo_url - for pkg in installed_repo_urls - ) - - if not package_match: - try: - agent_file.unlink() # Orphaned - remove it - files_removed += 1 - except Exception: - errors += 1 + return stats - # Also remove orphaned legacy chatmode files - for chatmode_file in agents_dir.glob("*-apm.chatmode.md"): - metadata = self._parse_header_metadata(chatmode_file) - - # Skip files without valid metadata - if not metadata: - continue - - # Try new format first: source_dependency contains full dependency string - source_dependency = metadata.get('SourceDependency', '') - - if source_dependency and source_dependency != 'unknown': - # New format: compare full dependency strings directly - # Both source_dependency and installed_deps are in canonical form - package_match = source_dependency in installed_deps - else: - # Fallback: old format using repo URL from Source field - source = metadata.get('Source', '') - if not source: - continue - - # Extract package repo URL from source - package_repo_url = None - if '(' in source and ')' in source: - package_repo_url = source.split('(')[1].split(')')[0].strip() - - if not package_repo_url: - continue - - # Normalize the repo URL for comparison - normalized_package_url = normalize_repo_url(package_repo_url) - - # Check if source package is still installed - package_match = any( - pkg == normalized_package_url or - (pkg + '.git') == normalized_package_url or - pkg == package_repo_url - for pkg in installed_repo_urls - ) - - if not package_match: + for pattern in ["*-apm.agent.md", "*-apm.chatmode.md"]: + for agent_file in agents_dir.glob(pattern): try: - chatmode_file.unlink() # Orphaned - remove it - files_removed += 1 + agent_file.unlink() + stats['files_removed'] += 1 except Exception: - errors += 1 + stats['errors'] += 1 - return {'files_removed': files_removed, 'errors': errors} + return stats def update_gitignore_for_integrated_agents(self, project_root: Path) -> bool: """Update .gitignore with pattern for integrated agents. diff --git a/src/apm_cli/integration/command_integrator.py b/src/apm_cli/integration/command_integrator.py index a82c4def7..1abe11319 100644 --- a/src/apm_cli/integration/command_integrator.py +++ b/src/apm_cli/integration/command_integrator.py @@ -7,8 +7,6 @@ from pathlib import Path from typing import List, Dict from dataclasses import dataclass -import hashlib -from datetime import datetime import frontmatter from apm_cli.compilation.link_resolver import UnifiedLinkResolver @@ -18,11 +16,11 @@ class CommandIntegrationResult: """Result of command integration operation.""" files_integrated: int - files_updated: int # Updated due to version/commit change - files_skipped: int # Unchanged (same version/commit) + files_updated: int + files_skipped: int target_paths: List[Path] gitignore_updated: bool - links_resolved: int = 0 # Number of context links resolved + links_resolved: int = 0 class CommandIntegrator: @@ -73,81 +71,6 @@ def find_prompt_files(self, package_path: Path) -> List[Path]: return prompt_files - def _parse_header_metadata(self, file_path: Path) -> dict: - """Parse metadata from frontmatter in an integrated command file. - - Args: - file_path: Path to the integrated command file - - Returns: - dict: Metadata extracted from frontmatter (version, commit, source, etc.) - Empty dict if no valid metadata found - """ - try: - post = frontmatter.load(file_path) - apm_metadata = post.metadata.get('apm', {}) - - if apm_metadata: - return { - 'Version': apm_metadata.get('version', ''), - 'Commit': apm_metadata.get('commit', ''), - 'Source': apm_metadata.get('source', ''), - 'SourceDependency': apm_metadata.get('source_dependency', ''), - 'ContentHash': apm_metadata.get('content_hash', ''), - } - return {} - except Exception: - return {} - - def _calculate_content_hash(self, file_path: Path) -> str: - """Calculate hash of command content (excluding metadata). - - Args: - file_path: Path to the command file - - Returns: - str: SHA256 hash of content, or empty string if error - """ - try: - post = frontmatter.load(file_path) - return hashlib.sha256(post.content.encode()).hexdigest() - except Exception: - return "" - - def _should_update_command(self, existing_header: dict, package_info, existing_file: Path = None) -> tuple: - """Determine if an existing command file should be updated. - - Args: - existing_header: Metadata from existing file's header - package_info: PackageInfo object with new package metadata - existing_file: Path to existing file for content hash verification - - Returns: - tuple[bool, bool]: (should_update, was_modified) - """ - if not existing_header: - return (True, False) - - new_version = package_info.package.version - new_commit = ( - package_info.resolved_reference.resolved_commit - if package_info.resolved_reference - else "unknown" - ) - - existing_version = existing_header.get('Version', '') - existing_commit = existing_header.get('Commit', '') - - was_modified = False - if existing_file and existing_file.exists(): - stored_hash = existing_header.get('ContentHash', '') - if stored_hash: - current_hash = self._calculate_content_hash(existing_file) - was_modified = (current_hash != stored_hash and current_hash != "") - - should_update = (existing_version != new_version or existing_commit != new_commit) - return (should_update, was_modified) - def _transform_prompt_to_command(self, source: Path) -> tuple: """Transform a .prompt.md file into Claude command format. @@ -195,7 +118,7 @@ def _transform_prompt_to_command(self, source: Path) -> tuple: return (command_name, new_post, warnings) def integrate_command(self, source: Path, target: Path, package_info, original_path: Path) -> int: - """Integrate a prompt file as a Claude command with metadata. + """Integrate a prompt file as a Claude command (verbatim copy with format conversion). Args: source: Source .prompt.md file path @@ -226,29 +149,6 @@ def integrate_command(self, source: Path, target: Path, package_info, original_p resolved_links = set(link_pattern.findall(resolved_content)) links_resolved = len(original_links - resolved_links) - # Calculate content hash for modification detection - content_hash = hashlib.sha256(post.content.encode()).hexdigest() - - # Add APM metadata for tracking - post.metadata['apm'] = { - 'source': package_info.package.name, - 'source_repo': package_info.package.source or "unknown", - 'source_dependency': package_info.get_canonical_dependency_string(), - 'version': package_info.package.version, - 'commit': ( - package_info.resolved_reference.resolved_commit - if package_info.resolved_reference - else "unknown" - ), - 'original_path': ( - str(original_path.relative_to(package_info.install_path)) - if original_path.is_relative_to(package_info.install_path) - else original_path.name - ), - 'installed_at': package_info.installed_at or datetime.now().isoformat(), - 'content_hash': content_hash - } - # Ensure target directory exists target.parent.mkdir(parents=True, exist_ok=True) @@ -286,8 +186,6 @@ def integrate_package_commands(self, package_info, project_root: Path) -> Comman self.link_resolver = UnifiedLinkResolver(project_root) files_integrated = 0 - files_updated = 0 - files_skipped = 0 target_paths = [] total_links_resolved = 0 @@ -303,39 +201,12 @@ def integrate_package_commands(self, package_info, project_root: Path) -> Comman command_name = f"{base_name}-apm" target_path = commands_dir / f"{command_name}.md" - # Check if update is needed - if target_path.exists(): - existing_header = self._parse_header_metadata(target_path) - should_update, was_modified = self._should_update_command( - existing_header, package_info, target_path - ) - - if was_modified: - # User modified the file - skip to preserve their changes - files_skipped += 1 - target_paths.append(target_path) - continue - - if not should_update: - # Same version/commit - skip - files_skipped += 1 - target_paths.append(target_path) - continue - - # Update needed - links_resolved = self.integrate_command( - prompt_file, target_path, package_info, prompt_file - ) - files_updated += 1 - total_links_resolved += links_resolved - else: - # New file - links_resolved = self.integrate_command( - prompt_file, target_path, package_info, prompt_file - ) - files_integrated += 1 - total_links_resolved += links_resolved - + # Always overwrite + links_resolved = self.integrate_command( + prompt_file, target_path, package_info, prompt_file + ) + files_integrated += 1 + total_links_resolved += links_resolved target_paths.append(target_path) # Update .gitignore @@ -343,8 +214,8 @@ def integrate_package_commands(self, package_info, project_root: Path) -> Comman return CommandIntegrationResult( files_integrated=files_integrated, - files_updated=files_updated, - files_skipped=files_skipped, + files_updated=0, + files_skipped=0, target_paths=target_paths, gitignore_updated=gitignore_updated, links_resolved=total_links_resolved @@ -379,60 +250,35 @@ def _update_gitignore(self, project_root: Path) -> bool: return True def sync_integration(self, apm_package, project_root: Path) -> Dict: - """Synchronize command integration - remove orphaned commands. - - Called during uninstall to clean up commands from removed packages. + """Remove all APM-managed command files for clean regeneration. Args: - apm_package: APMPackage with current dependencies + apm_package: APMPackage (unused, kept for interface compatibility) project_root: Root directory of the project Returns: Dict with cleanup stats: {'files_removed': int, 'errors': int} """ - commands_dir = project_root / ".claude" / "commands" + stats = {'files_removed': 0, 'errors': 0} + commands_dir = project_root / ".claude" / "commands" if not commands_dir.exists(): - return {'files_removed': 0, 'errors': 0} - - # Get current APM dependencies using get_unique_key() for proper matching - # For regular packages: "owner/repo" - # For virtual packages: "owner/repo/path/to/file.prompt.md" - current_deps = set() - if apm_package and apm_package.dependencies: - apm_deps = apm_package.dependencies.get('apm', []) - for dep in apm_deps: - # DependencyReference has get_unique_key() for proper virtual package handling - if hasattr(dep, 'get_unique_key'): - current_deps.add(dep.get_unique_key()) - elif hasattr(dep, 'repo_url'): - current_deps.add(dep.repo_url) - elif isinstance(dep, str): - current_deps.add(dep) - - files_removed = 0 - errors = 0 + return stats - # Scan integrated command files (those with -apm suffix) - for command_file in commands_dir.glob("*-apm.md"): + for cmd_file in commands_dir.glob("*-apm.md"): try: - metadata = self._parse_header_metadata(command_file) - source_dep = metadata.get('SourceDependency', '') - - if source_dep and source_dep not in current_deps: - # Package is no longer installed - remove command - command_file.unlink() - files_removed += 1 + cmd_file.unlink() + stats['files_removed'] += 1 except Exception: - errors += 1 + stats['errors'] += 1 - return {'files_removed': files_removed, 'errors': errors} + return stats def remove_package_commands(self, package_name: str, project_root: Path) -> int: - """Remove all commands for a specific package. + """Remove all APM-managed command files. Args: - package_name: Name of the package (e.g., "danielmeppiel/compliance-rules") + package_name: Name of the package (unused, all -apm files are removed) project_root: Root directory of the project Returns: @@ -444,17 +290,11 @@ def remove_package_commands(self, package_name: str, project_root: Path) -> int: return 0 files_removed = 0 - - for command_file in commands_dir.glob("*-apm.md"): + for cmd_file in commands_dir.glob("*-apm.md"): try: - metadata = self._parse_header_metadata(command_file) - source_dep = metadata.get('SourceDependency', '') - - if package_name in source_dep: - command_file.unlink() - files_removed += 1 + cmd_file.unlink() + files_removed += 1 except Exception: - # Skip files that can't be read or removed - continue with remaining files pass return files_removed diff --git a/src/apm_cli/integration/prompt_integrator.py b/src/apm_cli/integration/prompt_integrator.py index 994a98ffa..28100f09b 100644 --- a/src/apm_cli/integration/prompt_integrator.py +++ b/src/apm_cli/integration/prompt_integrator.py @@ -3,12 +3,8 @@ from pathlib import Path from typing import List, Dict from dataclasses import dataclass -import hashlib import re -from datetime import datetime -import frontmatter -from .utils import normalize_repo_url from apm_cli.compilation.link_resolver import UnifiedLinkResolver from apm_cli.primitives.discovery import discover_primitives @@ -17,8 +13,8 @@ class IntegrationResult: """Result of prompt integration operation.""" files_integrated: int - files_updated: int # Updated due to version/commit change - files_skipped: int # Unchanged (same version/commit) + files_updated: int # Kept for CLI compatibility, always 0 + files_skipped: int # Kept for CLI compatibility, always 0 target_paths: List[Path] gitignore_updated: bool links_resolved: int = 0 # Number of context links resolved @@ -68,182 +64,36 @@ def find_prompt_files(self, package_path: Path) -> List[Path]: return prompt_files - def _parse_header_metadata(self, file_path: Path) -> dict: - """Parse metadata from frontmatter or legacy header comment in an integrated prompt file. + def copy_prompt(self, source: Path, target: Path) -> int: + """Copy prompt file verbatim with link resolution. - Args: - file_path: Path to the integrated prompt file - - Returns: - dict: Metadata extracted from frontmatter/header (version, commit, source, etc.) - Empty dict if no valid metadata found or parsing fails - """ - try: - # Try parsing frontmatter first (new format) - post = frontmatter.load(file_path) - - # Check for nested apm metadata (new format) - apm_data = post.metadata.get('apm', {}) - if apm_data: - metadata = { - 'Version': apm_data.get('version', ''), - 'Commit': apm_data.get('commit', ''), - 'Source': f"{apm_data.get('source', '')} ({apm_data.get('source_repo', '')})", - 'SourceDependency': apm_data.get('source_dependency', ''), # Full dependency string - 'ContentHash': apm_data.get('content_hash', '') - } - return metadata - - # Fallback: Try legacy HTML comment format - content = file_path.read_text(encoding='utf-8') - - # Check if file starts with comment block - if not content.startswith(') - end_marker = content.find('-->') - if end_marker == -1: - return {} - - header_text = content[4:end_marker].strip() - - # Parse key-value pairs from header - metadata = {} - for line in header_text.split('\n'): - line = line.strip() - if ':' in line: - key, value = line.split(':', 1) - metadata[key.strip()] = value.strip() - - return metadata - except Exception: - # If any error occurs during parsing, return empty dict - return {} - - def _calculate_content_hash(self, file_path: Path) -> str: - """Calculate SHA256 hash of file content (excluding frontmatter). - - Args: - file_path: Path to the file - - Returns: - str: Hexadecimal hash of the content - """ - try: - post = frontmatter.load(file_path) - # Hash only the content, not the frontmatter - return hashlib.sha256(post.content.encode()).hexdigest() - except Exception: - return "" - - def _should_update_prompt(self, existing_header: dict, package_info, existing_file: Path = None) -> tuple[bool, bool]: - """Determine if an existing prompt file should be updated. - - Args: - existing_header: Metadata from existing file's header - package_info: PackageInfo object with new package metadata - existing_file: Path to existing file for content hash verification - - Returns: - tuple[bool, bool]: (should_update, was_modified) - - should_update: True if file should be updated - - was_modified: True if content was modified by user - """ - # If no valid header exists, update the file - if not existing_header: - return (True, False) - - # Get new version and commit - new_version = package_info.package.version - new_commit = ( - package_info.resolved_reference.resolved_commit - if package_info.resolved_reference - else "unknown" - ) - - # Get existing version and commit from header - existing_version = existing_header.get('Version', '') - existing_commit = existing_header.get('Commit', '') - - # Check for content modifications if we have the file path - was_modified = False - if existing_file and existing_file.exists(): - stored_hash = existing_header.get('ContentHash', '') - if stored_hash: - current_hash = self._calculate_content_hash(existing_file) - was_modified = (current_hash != stored_hash and current_hash != "") - - # Update if version or commit has changed - should_update = (existing_version != new_version or existing_commit != new_commit) - return (should_update, was_modified) - - def copy_prompt_with_metadata(self, source: Path, target: Path, package_info, original_path: Path) -> int: - """Copy prompt file with metadata embedded in frontmatter. - - If source has frontmatter, adds nested apm: metadata. - If source has no frontmatter, creates frontmatter with apm: metadata only. - Resolves context links before writing. + Copies file content as-is, only resolving context links. + No metadata injection. Args: source: Source file path target: Target file path - package_info: PackageInfo object with package metadata - original_path: Original path to the prompt file (for metadata) Returns: int: Number of links resolved """ - # Parse source file - post = frontmatter.load(source) - - # Resolve context links in content + content = source.read_text(encoding='utf-8') links_resolved = 0 + if self.link_resolver: - original_content = post.content resolved_content = self.link_resolver.resolve_links_for_installation( - content=post.content, + content=content, source_file=source, target_file=target ) - post.content = resolved_content - # Count how many links changed by comparing actual link content - if resolved_content != original_content: - import re - # Extract all links from both versions + if resolved_content != content: link_pattern = re.compile(r'\]\(([^)]+)\)') - original_links = set(link_pattern.findall(original_content)) + original_links = set(link_pattern.findall(content)) resolved_links = set(link_pattern.findall(resolved_content)) - # Count links that were changed links_resolved = len(original_links - resolved_links) + content = resolved_content - # Calculate content hash for modification detection (after link resolution) - content_hash = hashlib.sha256(post.content.encode()).hexdigest() - - # Add nested apm metadata - post.metadata['apm'] = { - 'source': package_info.package.name, - 'source_repo': package_info.package.source or "unknown", - 'source_dependency': package_info.get_canonical_dependency_string(), # Full dependency string for orphan detection - 'version': package_info.package.version, - 'commit': ( - package_info.resolved_reference.resolved_commit - if package_info.resolved_reference - else "unknown" - ), - 'original_path': ( - str(original_path.relative_to(package_info.install_path)) - if original_path.is_relative_to(package_info.install_path) - else original_path.name - ), - 'installed_at': package_info.installed_at or datetime.now().isoformat(), - 'content_hash': content_hash - } - - # Write to target with updated frontmatter - with open(target, 'w', encoding='utf-8') as f: - f.write(frontmatter.dumps(post)) - + target.write_text(content, encoding='utf-8') return links_resolved def get_target_filename(self, source_file: Path, package_name: str) -> str: @@ -266,12 +116,7 @@ def get_target_filename(self, source_file: Path, package_name: str) -> str: def integrate_package_prompts(self, package_info, project_root: Path) -> IntegrationResult: """Integrate all prompts from a package into .github/prompts/. - Implements smart update logic: - - First install: Copy with header and @ prefix - - Subsequent installs: - - Compare version/commit with existing file - - Update if different (re-copy with new header) - - Skip if unchanged (preserve file timestamps) + Always overwrites existing files (it's cheap). Resolves context links during integration. Args: @@ -306,142 +151,49 @@ def integrate_package_prompts(self, package_info, project_root: Path) -> Integra prompts_dir = project_root / ".github" / "prompts" prompts_dir.mkdir(parents=True, exist_ok=True) - # Process each prompt file + # Process each prompt file - always overwrite files_integrated = 0 - files_updated = 0 - files_skipped = 0 target_paths = [] total_links_resolved = 0 for source_file in prompt_files: - # Generate target filename target_filename = self.get_target_filename(source_file, package_info.package.name) target_path = prompts_dir / target_filename - # Check if target already exists - if target_path.exists(): - # Parse existing file's metadata - existing_header = self._parse_header_metadata(target_path) - - # Check if update is needed and if content was modified - should_update, was_modified = self._should_update_prompt( - existing_header, package_info, target_path - ) - - if should_update: - # Warn if user modified the content - if was_modified: - from apm_cli.cli import _rich_warning - _rich_warning( - f"⚠ Restoring modified file: {target_path.name} " - f"(your changes will be overwritten)" - ) - # Version or commit changed - update the file - links_resolved = self.copy_prompt_with_metadata(source_file, target_path, package_info, source_file) - total_links_resolved += links_resolved - files_updated += 1 - target_paths.append(target_path) - else: - # No change - skip to preserve file timestamp - files_skipped += 1 - else: - # New file - integrate it - links_resolved = self.copy_prompt_with_metadata(source_file, target_path, package_info, source_file) - total_links_resolved += links_resolved - files_integrated += 1 - target_paths.append(target_path) + links_resolved = self.copy_prompt(source_file, target_path) + total_links_resolved += links_resolved + files_integrated += 1 + target_paths.append(target_path) return IntegrationResult( files_integrated=files_integrated, - files_updated=files_updated, - files_skipped=files_skipped, + files_updated=0, + files_skipped=0, target_paths=target_paths, gitignore_updated=False, links_resolved=total_links_resolved ) def sync_integration(self, apm_package, project_root: Path) -> Dict[str, int]: - """Sync .github/prompts/ with currently installed packages. - - - Removes prompts from uninstalled packages (orphans) - - Updates prompts from updated packages - - Adds prompts from new packages - - Idempotent: safe to call anytime. Reuses existing smart update logic. + """Remove all APM-managed prompt files for clean regeneration. - Args: - apm_package: APMPackage with current dependencies - project_root: Root directory of the project - - Returns: - Dict with 'files_removed' and 'errors' counts + Uses nuke-and-regenerate approach: removes all *-apm.prompt.md files. + The caller re-integrates from currently installed packages. """ + stats = {'files_removed': 0, 'errors': 0} + prompts_dir = project_root / ".github" / "prompts" if not prompts_dir.exists(): - return {'files_removed': 0, 'errors': 0} - - # Build set of canonical dependency strings for comparison - # For virtual packages: owner/repo/path/to/file or owner/repo/collections/name - # For regular packages: owner/repo - installed_deps = set() - installed_repo_urls = set() # Fallback for old metadata format - for dep in apm_package.get_apm_dependencies(): - installed_deps.add(dep.get_canonical_dependency_string()) - installed_repo_urls.add(dep.repo_url) - - # Track cleanup statistics - files_removed = 0 - errors = 0 + return stats - # Remove orphaned prompts (from uninstalled packages) for prompt_file in prompts_dir.glob("*-apm.prompt.md"): - metadata = self._parse_header_metadata(prompt_file) - - # Skip files without valid metadata - they might be user's custom files - if not metadata: - continue - - # Try new format first: source_dependency contains full dependency string - source_dependency = metadata.get('SourceDependency', '') - - if source_dependency and source_dependency != 'unknown': - # New format: compare full dependency strings directly - # Both source_dependency and installed_deps are in canonical form - package_match = source_dependency in installed_deps - else: - # Fallback: old format using repo URL from Source field - source = metadata.get('Source', '') - if not source: - continue - - # Extract package repo URL from source - # Format: "package-name (owner/repo)" or "package-name (host.com/owner/repo)" - package_repo_url = None - if '(' in source and ')' in source: - package_repo_url = source.split('(')[1].split(')')[0].strip() - - if not package_repo_url: - continue - - # Normalize the repo URL for comparison - normalized_package_url = normalize_repo_url(package_repo_url) - - # Check if source package is still installed (using repo_url for backwards compat) - package_match = any( - pkg == normalized_package_url or - (pkg + '.git') == normalized_package_url or - pkg == package_repo_url - for pkg in installed_repo_urls - ) - - if not package_match: - try: - prompt_file.unlink() # Orphaned - remove it - files_removed += 1 - except Exception: - errors += 1 + try: + prompt_file.unlink() + stats['files_removed'] += 1 + except Exception: + stats['errors'] += 1 - return {'files_removed': files_removed, 'errors': errors} + return stats def update_gitignore_for_integrated_prompts(self, project_root: Path) -> bool: """Update .gitignore with pattern for integrated prompts. diff --git a/src/apm_cli/integration/skill_integrator.py b/src/apm_cli/integration/skill_integrator.py index b62efb051..2636523d2 100644 --- a/src/apm_cli/integration/skill_integrator.py +++ b/src/apm_cli/integration/skill_integrator.py @@ -253,9 +253,10 @@ def copy_skill_to_target( - Package type routing via should_install_skill() - Skill name validation/normalization - Directory structure preservation - - APM metadata injection for orphan detection - Compatibility copy to .claude/skills/ when .claude/ exists (T7) + Source SKILL.md is copied verbatim — no metadata injection. + Copies: - SKILL.md (required) - scripts/ (optional) @@ -306,10 +307,6 @@ def copy_skill_to_target( # This copies SKILL.md, scripts/, references/, assets/, etc. shutil.copytree(source_path, github_skill_dir) - # Add APM tracking metadata to SKILL.md for orphan detection - github_skill_md = github_skill_dir / "SKILL.md" - _add_apm_metadata(github_skill_md, package_info) - # === Secondary target: .claude/skills/ (T7 - compatibility copy) === claude_skill_dir: Path | None = None claude_dir = target_base / ".claude" @@ -328,45 +325,10 @@ def copy_skill_to_target( # Copy the entire skill folder (identical to github copy) shutil.copytree(source_path, claude_skill_dir) - - # Add APM tracking metadata - claude_skill_md = claude_skill_dir / "SKILL.md" - _add_apm_metadata(claude_skill_md, package_info) return (github_skill_dir, claude_skill_dir) -def _add_apm_metadata(skill_path: Path, package_info) -> None: - """Add APM tracking metadata to a SKILL.md file. - - This ensures sync_integration can identify APM-managed skills for cleanup. - - Args: - skill_path: Path to SKILL.md file - package_info: PackageInfo with package metadata - """ - from datetime import datetime - post = frontmatter.load(skill_path) - - # Add nested metadata for APM tracking - if 'metadata' not in post.metadata: - post.metadata['metadata'] = {} - - post.metadata['metadata']['apm_package'] = package_info.get_canonical_dependency_string() - post.metadata['metadata']['apm_version'] = package_info.package.version - post.metadata['metadata']['apm_commit'] = ( - package_info.resolved_reference.resolved_commit - if package_info.resolved_reference - else "unknown" - ) - post.metadata['metadata']['apm_installed_at'] = ( - package_info.installed_at or datetime.now().isoformat() - ) - - with open(skill_path, 'w', encoding='utf-8') as f: - f.write(frontmatter.dumps(post)) - - class SkillIntegrator: """Handles generation of SKILL.md files for Claude Code integration. @@ -789,8 +751,8 @@ def _integrate_native_skill( The skill folder name is the source folder name (e.g., `mcp-builder`), validated and normalized per the agentskills.io spec. - We add APM tracking metadata to the copied SKILL.md so sync_integration - can properly identify and clean up orphaned skills. + Source SKILL.md is copied verbatim — no metadata injection. Orphan + detection uses apm.lock via directory name matching instead. T7 Enhancement: Also copies to .claude/skills/ when .claude/ folder exists. This ensures Claude Code users get skills while not polluting projects @@ -837,102 +799,41 @@ def _integrate_native_skill( github_skill_dir = project_root / ".github" / "skills" / skill_name github_skill_md = github_skill_dir / "SKILL.md" - # Check if we need to update - skill_created = False - skill_updated = False - skill_skipped = False - - if github_skill_md.exists(): - # Check existing metadata for version/commit changes - existing_metadata = self._parse_skill_metadata(github_skill_md) - current_version = package_info.package.version - current_commit = ( - package_info.resolved_reference.resolved_commit - if package_info.resolved_reference - else "unknown" - ) - - if (existing_metadata.get('Version') == current_version and - existing_metadata.get('Commit') == current_commit): - skill_skipped = True - else: - skill_updated = True - else: - skill_created = True + # Always copy — source integrity is preserved, orphan detection uses apm.lock + skill_created = not github_skill_dir.exists() + skill_updated = not skill_created files_copied = 0 claude_skill_dir: Path | None = None - if skill_created or skill_updated: - # === Copy to .github/skills/ (primary) === - # Remove existing skill directory if updating - if github_skill_dir.exists(): - shutil.rmtree(github_skill_dir) - - # Create parent directory - github_skill_dir.parent.mkdir(parents=True, exist_ok=True) - - # Copy the entire package directory to the skill location - shutil.copytree(package_path, github_skill_dir) - - # Add APM tracking metadata to the SKILL.md for orphan detection - self._add_apm_metadata_to_skill(github_skill_md, package_info) + # === Copy to .github/skills/ (primary) === + if github_skill_dir.exists(): + shutil.rmtree(github_skill_dir) + + github_skill_dir.parent.mkdir(parents=True, exist_ok=True) + shutil.copytree(package_path, github_skill_dir) + + files_copied = sum(1 for _ in github_skill_dir.rglob('*') if _.is_file()) + + # === T7: Copy to .claude/skills/ (secondary - compatibility) === + claude_dir = project_root / ".claude" + if claude_dir.exists() and claude_dir.is_dir(): + claude_skill_dir = claude_dir / "skills" / skill_name - # Count files copied - files_copied = sum(1 for _ in github_skill_dir.rglob('*') if _.is_file()) + if claude_skill_dir.exists(): + shutil.rmtree(claude_skill_dir) - # === T7: Copy to .claude/skills/ (secondary - compatibility) === - claude_dir = project_root / ".claude" - if claude_dir.exists() and claude_dir.is_dir(): - claude_skill_dir = claude_dir / "skills" / skill_name - - # Remove existing if updating - if claude_skill_dir.exists(): - shutil.rmtree(claude_skill_dir) - - # Create parent directory - claude_skill_dir.parent.mkdir(parents=True, exist_ok=True) - - # Copy the entire package directory (identical to github copy) - shutil.copytree(package_path, claude_skill_dir) - - # Add APM tracking metadata - claude_skill_md = claude_skill_dir / "SKILL.md" - self._add_apm_metadata_to_skill(claude_skill_md, package_info) + claude_skill_dir.parent.mkdir(parents=True, exist_ok=True) + shutil.copytree(package_path, claude_skill_dir) return SkillIntegrationResult( skill_created=skill_created, skill_updated=skill_updated, - skill_skipped=skill_skipped, - skill_path=github_skill_md if (skill_created or skill_updated) else None, + skill_skipped=False, + skill_path=github_skill_md, references_copied=files_copied, links_resolved=0 ) - - def _add_apm_metadata_to_skill(self, skill_path: Path, package_info) -> None: - """Add APM tracking metadata to a SKILL.md file. - - This ensures sync_integration can identify APM-managed skills for cleanup. - """ - post = frontmatter.load(skill_path) - - # Add nested metadata for APM tracking - if 'metadata' not in post.metadata: - post.metadata['metadata'] = {} - - post.metadata['metadata']['apm_package'] = package_info.get_canonical_dependency_string() - post.metadata['metadata']['apm_version'] = package_info.package.version - post.metadata['metadata']['apm_commit'] = ( - package_info.resolved_reference.resolved_commit - if package_info.resolved_reference - else "unknown" - ) - post.metadata['metadata']['apm_installed_at'] = ( - package_info.installed_at or datetime.now().isoformat() - ) - - with open(skill_path, 'w', encoding='utf-8') as f: - f.write(frontmatter.dumps(post)) def integrate_package_skill(self, package_info, project_root: Path) -> SkillIntegrationResult: """Generate SKILL.md for a package in .github/skills/ directory. @@ -1106,7 +1007,8 @@ def sync_integration(self, apm_package, project_root: Path) -> Dict[str, int]: """Sync .github/skills/ and .claude/skills/ with currently installed packages. Removes skill directories for packages that are no longer installed. - Uses apm_package metadata in SKILL.md to identify APM-managed skills. + Uses npm-style approach: derives expected skill directory names from + installed dependencies and removes any directory not in that set. T7 Enhancement: Cleans both .github/skills/ and .claude/skills/ locations. @@ -1119,33 +1021,42 @@ def sync_integration(self, apm_package, project_root: Path) -> Dict[str, int]: """ stats = {'files_removed': 0, 'errors': 0} - # Get canonical dependency strings for all installed packages - installed_packages = set() + # Build set of expected skill directory names from installed packages + installed_skill_names = set() for dep in apm_package.get_apm_dependencies(): - installed_packages.add(dep.get_canonical_dependency_string()) + # Derive skill name the same way copy_native_skill / copy_skill_to_target does + raw_name = dep.repo_url.split('/')[-1] + if dep.is_virtual and dep.virtual_path: + raw_name = dep.virtual_path.split('/')[-1] + is_valid, _ = validate_skill_name(raw_name) + skill_name = raw_name if is_valid else normalize_skill_name(raw_name) + installed_skill_names.add(skill_name) # Clean .github/skills/ (primary) github_skills_dir = project_root / ".github" / "skills" if github_skills_dir.exists(): - result = self._clean_orphaned_skills(github_skills_dir, installed_packages) + result = self._clean_orphaned_skills(github_skills_dir, installed_skill_names) stats['files_removed'] += result['files_removed'] stats['errors'] += result['errors'] # Clean .claude/skills/ (secondary - T7 compatibility) claude_skills_dir = project_root / ".claude" / "skills" if claude_skills_dir.exists(): - result = self._clean_orphaned_skills(claude_skills_dir, installed_packages) + result = self._clean_orphaned_skills(claude_skills_dir, installed_skill_names) stats['files_removed'] += result['files_removed'] stats['errors'] += result['errors'] return stats - def _clean_orphaned_skills(self, skills_dir: Path, installed_packages: set) -> Dict[str, int]: + def _clean_orphaned_skills(self, skills_dir: Path, installed_skill_names: set) -> Dict[str, int]: """Clean orphaned skills from a skills directory. + Uses npm-style approach: any skill directory not matching an installed + package name is considered orphaned and removed. + Args: skills_dir: Path to skills directory (.github/skills/ or .claude/skills/) - installed_packages: Set of canonical dependency strings for installed packages + installed_skill_names: Set of expected skill directory names Returns: Dict with cleanup statistics @@ -1155,16 +1066,10 @@ def _clean_orphaned_skills(self, skills_dir: Path, installed_packages: set) -> D for skill_subdir in skills_dir.iterdir(): if skill_subdir.is_dir(): - skill_md = skill_subdir / "SKILL.md" - if skill_md.exists(): + if skill_subdir.name not in installed_skill_names: try: - metadata = self._parse_skill_metadata(skill_md) - apm_package_ref = metadata.get('Package') - if apm_package_ref: # This is an APM-managed skill - if apm_package_ref not in installed_packages: - # Remove orphaned skill directory - shutil.rmtree(skill_subdir) - files_removed += 1 + shutil.rmtree(skill_subdir) + files_removed += 1 except Exception: errors += 1 diff --git a/tests/unit/integration/test_agent_integrator.py b/tests/unit/integration/test_agent_integrator.py index 6b487db50..fb6da8963 100644 --- a/tests/unit/integration/test_agent_integrator.py +++ b/tests/unit/integration/test_agent_integrator.py @@ -97,41 +97,18 @@ def test_find_agent_files_mixed_formats(self): extensions = {tuple(p.name.split('.')[-2:]) for p in agents} assert extensions == {('agent', 'md'), ('chatmode', 'md')} - def test_copy_agent_with_metadata(self): - """Test copying agent file with metadata in frontmatter.""" + def test_copy_agent_verbatim(self): + """Test copying agent file verbatim (no metadata injection).""" source = self.project_root / "source.agent.md" target = self.project_root / "target.agent.md" source_content = "# Security Agent\n\nSome agent content." source.write_text(source_content) - package = APMPackage( - name="test-pkg", - version="1.0.0", - package_path=Path("/fake/path"), - source="github.com/test/repo" - ) - resolved_ref = ResolvedReference( - original_ref="main", - ref_type=GitReferenceType.BRANCH, - resolved_commit="abc123", - ref_name="main" - ) - package_info = PackageInfo( - package=package, - install_path=Path("/fake/install"), - resolved_reference=resolved_ref, - installed_at="2024-11-13T10:00:00" - ) - - self.integrator.copy_agent_with_metadata(source, target, package_info, source) + self.integrator.copy_agent(source, target) target_content = target.read_text() - assert "---" in target_content # YAML frontmatter - assert "apm:" in target_content - assert "version: 1.0.0" in target_content - assert "commit: abc123" in target_content - assert "Some agent content" in target_content + assert target_content == source_content def test_get_target_filename_agent_format(self): """Test target filename generation with -apm suffix for .agent.md.""" @@ -185,8 +162,8 @@ def test_integrate_package_agents_creates_directory(self): assert result.files_integrated == 1 assert (self.project_root / ".github" / "agents").exists() - def test_integrate_package_agents_skips_unchanged_files(self): - """Test that integration skips files with same version and commit.""" + def test_integrate_package_agents_always_overwrites(self): + """Test that integration always overwrites existing files.""" package_dir = self.project_root / "package" package_dir.mkdir() (package_dir / "security.agent.md").write_text("# Security Agent") @@ -194,20 +171,8 @@ def test_integrate_package_agents_skips_unchanged_files(self): github_agents = self.project_root / ".github" / "agents" github_agents.mkdir(parents=True) - # Pre-create the target file with matching frontmatter - existing_content = """--- -apm: - source: test-pkg - source_repo: github.com/test/repo - version: 1.0.0 - commit: abc123 - original_path: security.agent.md - installed_at: '2024-01-01T00:00:00' - content_hash: da39a3ee5e6b4b0d3255bfef95601890afd80709 ---- - -# Existing""" - (github_agents / "security-apm.agent.md").write_text(existing_content) + # Pre-create the target file with old content + (github_agents / "security-apm.agent.md").write_text("# Old Content") package = APMPackage( name="test-pkg", @@ -230,9 +195,12 @@ def test_integrate_package_agents_skips_unchanged_files(self): result = self.integrator.integrate_package_agents(package_info, self.project_root) - assert result.files_integrated == 0 + assert result.files_integrated == 1 assert result.files_updated == 0 - assert result.files_skipped == 1 + assert result.files_skipped == 0 + # Verify content was overwritten + content = (github_agents / "security-apm.agent.md").read_text() + assert content == "# Security Agent" def test_update_gitignore_adds_patterns(self): """Test that gitignore is updated with integrated agents patterns.""" @@ -255,169 +223,26 @@ def test_update_gitignore_skips_if_exists(self): assert updated == False - # ========== Header-based Versioning Tests ========== - - def test_parse_header_metadata_valid(self): - """Test parsing metadata from valid YAML frontmatter.""" - header_content = """--- -apm: - source: security-standards - source_repo: danielmeppiel/security-standards - version: 1.0.0 - commit: abc123def456 - original_path: security.agent.md - installed_at: '2024-11-13T10:30:00Z' + # ========== Verbatim Copy Tests ========== + + def test_copy_agent_preserves_frontmatter(self): + """Test that copy_agent preserves existing YAML frontmatter as-is.""" + source = self.project_root / "source.agent.md" + target = self.project_root / "target.agent.md" + + source_content = """--- +description: My agent +tools: [] --- # Agent content here""" + source.write_text(source_content) - test_file = self.project_root / "test.agent.md" - test_file.write_text(header_content) - - metadata = self.integrator._parse_header_metadata(test_file) - - assert metadata['Source'] == 'security-standards (danielmeppiel/security-standards)' - assert metadata['Version'] == '1.0.0' - assert metadata['Commit'] == 'abc123def456' - assert metadata['Original'] == 'security.agent.md' - assert metadata['Installed'] == '2024-11-13T10:30:00Z' - - def test_parse_header_metadata_no_header(self): - """Test parsing file without header returns empty dict.""" - test_file = self.project_root / "test.agent.md" - test_file.write_text("# Just content, no header") - - metadata = self.integrator._parse_header_metadata(test_file) - - assert metadata == {} - - def test_parse_header_metadata_malformed(self): - """Test parsing malformed header returns empty dict.""" - test_file = self.project_root / "test.agent.md" - test_file.write_text(" - -# Existing""" - (github_prompts / "test-apm.prompt.md").write_text(existing_content) + # Pre-create the target file with old content + (github_prompts / "test-apm.prompt.md").write_text("# Old Content") package = APMPackage( name="test-pkg", @@ -182,9 +173,15 @@ def test_integrate_package_prompts_skips_unchanged_files(self): result = self.integrator.integrate_package_prompts(package_info, self.project_root) - assert result.files_integrated == 0 + # Always counts as integrated (overwrite) + assert result.files_integrated == 1 assert result.files_updated == 0 - assert result.files_skipped == 1 + assert result.files_skipped == 0 + + # Verify content was overwritten + content = (github_prompts / "test-apm.prompt.md").read_text() + assert "# New Content" in content + assert "# Old Content" not in content def test_update_gitignore_adds_pattern(self): """Test that gitignore is updated with integrated prompts pattern.""" @@ -206,171 +203,12 @@ def test_update_gitignore_skips_if_exists(self): assert updated == False - # ========== Header-based Versioning Tests ========== - - def test_parse_header_metadata_valid(self): - """Test parsing metadata from a valid header.""" - header_content = """ - -# Prompt content here""" - - test_file = self.project_root / "test.prompt.md" - test_file.write_text(header_content) - - metadata = self.integrator._parse_header_metadata(test_file) - - assert metadata['Source'] == 'design-guidelines (danielmeppiel/design-guidelines)' - assert metadata['Version'] == '1.0.0' - assert metadata['Commit'] == 'abc123def456' - assert metadata['Original'] == 'design-review.prompt.md' - assert metadata['Installed'] == '2024-11-13T10:30:00Z' - - def test_parse_header_metadata_no_header(self): - """Test parsing file without header returns empty dict.""" - test_file = self.project_root / "test.prompt.md" - test_file.write_text("# Just content, no header") - - metadata = self.integrator._parse_header_metadata(test_file) - - assert metadata == {} - - def test_parse_header_metadata_malformed(self): - """Test parsing malformed header returns empty dict.""" - test_file = self.project_root / "test.prompt.md" - test_file.write_text(" - -# Design Review""" - (github_prompts / "design-review-apm.prompt.md").write_text(prompt1) - - prompt2 = """ - -# Compliance Audit""" - (github_prompts / "compliance-audit-apm.prompt.md").write_text(prompt2) - - # Create APM package with only one dependency (design-guidelines uninstalled) - from apm_cli.models.apm_package import DependencyReference + # Create multiple APM-managed prompt files + (github_prompts / "design-review-apm.prompt.md").write_text("# Design Review") + (github_prompts / "compliance-audit-apm.prompt.md").write_text("# Compliance Audit") apm_package = Mock() - apm_package.get_apm_dependencies.return_value = [ - DependencyReference( - repo_url="danielmeppiel/compliance-rules", - reference="main" - ) - ] - # Run sync - self.integrator.sync_integration(apm_package, self.project_root) + result = self.integrator.sync_integration(apm_package, self.project_root) - # Verify orphaned prompt removed, existing prompt preserved + assert result['files_removed'] == 2 assert not (github_prompts / "design-review-apm.prompt.md").exists() - assert (github_prompts / "compliance-audit-apm.prompt.md").exists() + assert not (github_prompts / "compliance-audit-apm.prompt.md").exists() - def test_sync_integration_preserves_installed_prompts(self): - """Test that sync doesn't remove prompts from installed packages.""" + def test_sync_integration_preserves_non_apm_files(self): + """Test that sync does not remove files without -apm suffix.""" github_prompts = self.project_root / ".github" / "prompts" github_prompts.mkdir(parents=True) - # Create integrated prompt - prompt1 = """ - -# Design Review""" - (github_prompts / "design-review-apm.prompt.md").write_text(prompt1) - - # Create APM package with the dependency still installed - from apm_cli.models.apm_package import DependencyReference + # Create both APM and non-APM files + (github_prompts / "test-apm.prompt.md").write_text("# APM prompt") + (github_prompts / "my-custom.prompt.md").write_text("# Custom prompt") + (github_prompts / "readme.md").write_text("# Readme") apm_package = Mock() - apm_package.get_apm_dependencies.return_value = [ - DependencyReference( - repo_url="danielmeppiel/design-guidelines", - reference="main" - ) - ] - # Run sync - self.integrator.sync_integration(apm_package, self.project_root) + result = self.integrator.sync_integration(apm_package, self.project_root) - # Verify prompt still exists - assert (github_prompts / "design-review-apm.prompt.md").exists() + assert result['files_removed'] == 1 + assert not (github_prompts / "test-apm.prompt.md").exists() + assert (github_prompts / "my-custom.prompt.md").exists() + assert (github_prompts / "readme.md").exists() def test_sync_integration_handles_missing_prompts_dir(self): """Test that sync gracefully handles missing .github/prompts/ directory.""" - from apm_cli.models.apm_package import DependencyReference - apm_package = Mock() - apm_package.get_apm_dependencies.return_value = [ - DependencyReference( - repo_url="danielmeppiel/test", - reference="main" - ) - ] # Should not raise exception - self.integrator.sync_integration(apm_package, self.project_root) + result = self.integrator.sync_integration(apm_package, self.project_root) + + assert result['files_removed'] == 0 + assert result['errors'] == 0 - def test_sync_integration_handles_files_without_metadata(self): - """Test that sync preserves files without valid metadata headers (user's custom files).""" + def test_sync_integration_ignores_apm_package_param(self): + """Test that sync removes all APM files regardless of installed packages.""" github_prompts = self.project_root / ".github" / "prompts" github_prompts.mkdir(parents=True) - # Create file without proper header - could be user's custom prompt - (github_prompts / "custom-apm.prompt.md").write_text("# Custom prompt without header") + (github_prompts / "design-review-apm.prompt.md").write_text("# Design Review") + # Even with matching dependencies, sync removes everything from apm_cli.models.apm_package import DependencyReference - apm_package = Mock() - apm_package.get_apm_dependencies.return_value = [] + apm_package.get_apm_dependencies.return_value = [ + DependencyReference( + repo_url="danielmeppiel/design-guidelines", + reference="main" + ) + ] - # Should not raise exception - self.integrator.sync_integration(apm_package, self.project_root) + result = self.integrator.sync_integration(apm_package, self.project_root) - # File without header should be preserved (not removed) - it's a user's file - assert (github_prompts / "custom-apm.prompt.md").exists() + assert result['files_removed'] == 1 + assert not (github_prompts / "design-review-apm.prompt.md").exists() class TestPromptSuffixPattern: diff --git a/tests/unit/integration/test_skill_integrator.py b/tests/unit/integration/test_skill_integrator.py index 3ddf241e3..e2a429a7e 100644 --- a/tests/unit/integration/test_skill_integrator.py +++ b/tests/unit/integration/test_skill_integrator.py @@ -956,22 +956,10 @@ def test_sync_integration_removes_orphaned_subdirectory_skill(self): like ComposioHQ/awesome-claude-skills/mcp-builder. """ # Simulate an installed skill from a subdirectory package - # Skill name uses the folder name directly: mcp-builder skill_name = "mcp-builder" skill_dir = self.project_root / ".github" / "skills" / skill_name skill_dir.mkdir(parents=True) - - # Create SKILL.md with APM metadata (matching _generate_skill_file's nested format) - skill_content = """--- -name: mcp-builder -description: MCP Builder Skill -metadata: - apm_package: ComposioHQ/awesome-claude-skills/mcp-builder - apm_version: '1.0.0' ---- -# MCP Builder Skill -""" - (skill_dir / "SKILL.md").write_text(skill_content) + (skill_dir / "SKILL.md").write_text("---\nname: mcp-builder\n---\n# MCP Builder Skill\n") # Now simulate that this package was uninstalled (not in dependencies) apm_package = Mock() @@ -986,22 +974,10 @@ def test_sync_integration_removes_orphaned_subdirectory_skill(self): def test_sync_integration_keeps_installed_subdirectory_skill(self): """Test that sync keeps skills for still-installed subdirectory packages.""" # Simulate an installed skill from a subdirectory package - # Skill name uses the folder name directly: mcp-builder skill_name = "mcp-builder" skill_dir = self.project_root / ".github" / "skills" / skill_name skill_dir.mkdir(parents=True) - - # Create SKILL.md with APM metadata (matching _generate_skill_file's nested format) - skill_content = """--- -name: mcp-builder -description: MCP Builder Skill -metadata: - apm_package: ComposioHQ/awesome-claude-skills/mcp-builder - apm_version: '1.0.0' ---- -# MCP Builder Skill -""" - (skill_dir / "SKILL.md").write_text(skill_content) + (skill_dir / "SKILL.md").write_text("---\nname: mcp-builder\n---\n# MCP Builder Skill\n") # Simulate that this package is still installed dep_ref = DependencyReference.parse("ComposioHQ/awesome-claude-skills/mcp-builder") @@ -1578,8 +1554,7 @@ def test_copy_skill_preserves_skill_md_content_exactly(self): # Read copied content copied_content = target_skill_md.read_text() - # The body content should be preserved exactly - # (frontmatter will have APM metadata added) + # The content should be preserved exactly (verbatim copy, no mutation) assert "# MCP Builder" in copied_content assert "This skill helps you build **Model Context Protocol** servers." in copied_content assert "- TypeScript support" in copied_content @@ -1883,13 +1858,12 @@ def test_copy_skill_creates_github_skills_directory(self): # ========== Test T6: APM metadata is added for orphan detection ========== - def test_copy_skill_adds_apm_metadata(self): - """Test that APM tracking metadata is added to copied SKILL.md.""" - import frontmatter - + def test_copy_skill_preserves_source_integrity(self): + """Test that copied SKILL.md is identical to source (no metadata injection).""" skill_source = self.apm_modules / "owner" / "my-skill" skill_source.mkdir(parents=True) - (skill_source / "SKILL.md").write_text("---\nname: my-skill\ndescription: Test\n---\n# My Skill") + original_content = "---\nname: my-skill\ndescription: Test\n---\n# My Skill" + (skill_source / "SKILL.md").write_text(original_content) package_info = self._create_package_info( name="my-skill", @@ -1903,18 +1877,9 @@ def test_copy_skill_adds_apm_metadata(self): assert github_path is not None - # Parse the copied SKILL.md - post = frontmatter.load(github_path / "SKILL.md") - - # Verify APM metadata is present - assert 'metadata' in post.metadata - apm_metadata = post.metadata['metadata'] - assert 'apm_package' in apm_metadata - assert 'apm_version' in apm_metadata - assert apm_metadata['apm_version'] == '2.5.0' - assert 'apm_commit' in apm_metadata - assert apm_metadata['apm_commit'] == 'xyz789' - assert 'apm_installed_at' in apm_metadata + # Copied SKILL.md must be identical to the source + copied_content = (github_path / "SKILL.md").read_text() + assert copied_content == original_content class TestNativeSkillIntegration: @@ -2412,28 +2377,14 @@ def test_copy_skill_to_target_returns_none_claude_when_no_claude_dir(self): def test_sync_removes_orphans_from_both_locations(self): """Test that sync_integration removes orphaned skills from both locations.""" - # Create skill directories in both locations + # Create skill directories in both locations (no metadata needed) github_skill = self.project_root / ".github" / "skills" / "orphan-skill" github_skill.mkdir(parents=True) - (github_skill / "SKILL.md").write_text("""--- -name: orphan-skill -metadata: - apm_package: owner/orphan-skill - apm_version: '1.0.0' ---- -# Orphan Skill -""") + (github_skill / "SKILL.md").write_text("# Orphan Skill\n") claude_skill = self.project_root / ".claude" / "skills" / "orphan-skill" claude_skill.mkdir(parents=True) - (claude_skill / "SKILL.md").write_text("""--- -name: orphan-skill -metadata: - apm_package: owner/orphan-skill - apm_version: '1.0.0' ---- -# Orphan Skill -""") + (claude_skill / "SKILL.md").write_text("# Orphan Skill\n") # Mock apm_package with no dependencies (orphan) apm_package = Mock() @@ -2448,34 +2399,20 @@ def test_sync_removes_orphans_from_both_locations(self): def test_sync_keeps_installed_skills_in_both_locations(self): """Test that sync_integration keeps installed skills in both locations.""" - # Create skill directories in both locations + # Create skill directories in both locations (no metadata needed) skill_name = "installed-skill" - canonical_ref = "owner/installed-skill" github_skill = self.project_root / ".github" / "skills" / skill_name github_skill.mkdir(parents=True) - (github_skill / "SKILL.md").write_text(f"""--- -name: {skill_name} -metadata: - apm_package: {canonical_ref} - apm_version: '1.0.0' ---- -# Installed Skill -""") + (github_skill / "SKILL.md").write_text("# Installed Skill\n") claude_skill = self.project_root / ".claude" / "skills" / skill_name claude_skill.mkdir(parents=True) - (claude_skill / "SKILL.md").write_text(f"""--- -name: {skill_name} -metadata: - apm_package: {canonical_ref} - apm_version: '1.0.0' ---- -# Installed Skill -""") + (claude_skill / "SKILL.md").write_text("# Installed Skill\n") # Mock apm_package with this dependency installed - dep_ref = DependencyReference.parse(canonical_ref) + # "owner/installed-skill" → skill dir name "installed-skill" + dep_ref = DependencyReference.parse("owner/installed-skill") apm_package = Mock() apm_package.get_apm_dependencies.return_value = [dep_ref] @@ -2493,13 +2430,7 @@ def test_sync_only_cleans_claude_skills_when_claude_exists(self): # Only .github/ exists, not .claude/ github_skill = self.project_root / ".github" / "skills" / "orphan-skill" github_skill.mkdir(parents=True) - (github_skill / "SKILL.md").write_text("""--- -name: orphan-skill -metadata: - apm_package: owner/orphan-skill ---- -# Orphan Skill -""") + (github_skill / "SKILL.md").write_text("# Orphan Skill\n") apm_package = Mock() apm_package.get_apm_dependencies.return_value = [] @@ -2513,16 +2444,15 @@ def test_sync_only_cleans_claude_skills_when_claude_exists(self): # ========== Test: APM metadata added to both copies ========== - def test_apm_metadata_added_to_both_copies(self): - """Test that APM metadata is added to SKILL.md in both locations.""" - import frontmatter - + def test_native_skill_copied_verbatim_to_both_locations(self): + """Test that native SKILL.md is copied verbatim (no metadata injection) to both locations.""" # Create .claude/ directory (self.project_root / ".claude").mkdir() skill_source = self.apm_modules / "owner" / "my-skill" skill_source.mkdir(parents=True) - (skill_source / "SKILL.md").write_text("---\nname: my-skill\ndescription: Test\n---\n# My Skill") + original_content = "---\nname: my-skill\ndescription: Test\n---\n# My Skill" + (skill_source / "SKILL.md").write_text(original_content) package_info = self._create_package_info( name="my-skill", @@ -2534,70 +2464,52 @@ def test_apm_metadata_added_to_both_copies(self): self.integrator.integrate_package_skill(package_info, self.project_root) - # Check .github/skills/ - github_post = frontmatter.load(self.project_root / ".github" / "skills" / "my-skill" / "SKILL.md") - assert 'metadata' in github_post.metadata - assert github_post.metadata['metadata']['apm_version'] == '2.0.0' - assert github_post.metadata['metadata']['apm_commit'] == 'xyz789' - - # Check .claude/skills/ - claude_post = frontmatter.load(self.project_root / ".claude" / "skills" / "my-skill" / "SKILL.md") - assert 'metadata' in claude_post.metadata - assert claude_post.metadata['metadata']['apm_version'] == '2.0.0' - assert claude_post.metadata['metadata']['apm_commit'] == 'xyz789' + # Both copies must be identical to the source + github_content = (self.project_root / ".github" / "skills" / "my-skill" / "SKILL.md").read_text() + assert github_content == original_content + + claude_content = (self.project_root / ".claude" / "skills" / "my-skill" / "SKILL.md").read_text() + assert claude_content == original_content # ========== T12: Additional orphan cleanup tests ========== - def test_sync_preserves_user_created_skills_without_apm_metadata(self): - """Test that sync does NOT remove user-created skills without APM metadata. + def test_sync_removes_all_unknown_skill_dirs(self): + """Test that sync removes ALL skill directories not matching installed packages. - User-created skill directories (without apm_package in metadata) should - never be removed during sync. This prevents data loss of manually created skills. + Uses npm-style approach: .github/skills/ is fully APM-managed. + Any directory not matching an installed package name is removed. """ - # Create a user-created skill in .github/skills/ (no APM metadata) - user_skill = self.project_root / ".github" / "skills" / "user-created-skill" - user_skill.mkdir(parents=True) - (user_skill / "SKILL.md").write_text("""--- -name: user-created-skill -description: A skill I created manually ---- -# My Custom Skill - -This is a skill I created by hand, not via APM. -""") + # Create a skill dir not matching any installed package + unknown_skill = self.project_root / ".github" / "skills" / "unknown-skill" + unknown_skill.mkdir(parents=True) + (unknown_skill / "SKILL.md").write_text("---\nname: unknown\n---\n# Custom Skill\n") - # Create a user-created skill in .claude/skills/ (no APM metadata) + # Create another with no SKILL.md (self.project_root / ".claude").mkdir() - claude_user_skill = self.project_root / ".claude" / "skills" / "my-workflow" - claude_user_skill.mkdir(parents=True) - (claude_user_skill / "SKILL.md").write_text("""--- -name: my-workflow -description: Custom workflow ---- -# Workflow -""") + claude_unknown = self.project_root / ".claude" / "skills" / "my-workflow" + claude_unknown.mkdir(parents=True) + (claude_unknown / "SKILL.md").write_text("---\nname: my-workflow\n---\n# Workflow\n") - # Run sync with no dependencies (simulates `apm prune`) + # Run sync with no dependencies apm_package = Mock() apm_package.get_apm_dependencies.return_value = [] result = self.integrator.sync_integration(apm_package, self.project_root) - # User skills should NOT be removed (no apm_package metadata) - assert result['files_removed'] == 0 - assert user_skill.exists() - assert claude_user_skill.exists() + # All unknown dirs should be removed (npm-style) + assert result['files_removed'] == 2 + assert not unknown_skill.exists() + assert not claude_unknown.exists() - def test_sync_skips_skill_dirs_without_skill_md(self): - """Test that sync gracefully handles skill directories without SKILL.md. + def test_sync_removes_skill_dirs_without_skill_md(self): + """Test that sync removes orphaned skill directories even without SKILL.md. - Skill directories without a SKILL.md file should be skipped, not removed. - This can happen with corrupted installs or partial cleanups. + Uses npm-style approach: any directory not matching an installed package + name is removed, regardless of its contents. """ # Create a skill directory without SKILL.md empty_skill = self.project_root / ".github" / "skills" / "empty-skill" empty_skill.mkdir(parents=True) - # Just add some other file (empty_skill / "README.md").write_text("# Some file") apm_package = Mock() @@ -2605,16 +2517,15 @@ def test_sync_skips_skill_dirs_without_skill_md(self): result = self.integrator.sync_integration(apm_package, self.project_root) - # Should not be removed (no SKILL.md to check metadata) - assert result['files_removed'] == 0 - assert empty_skill.exists() + # Should be removed (not in installed set) + assert result['files_removed'] == 1 + assert not empty_skill.exists() - def test_sync_handles_malformed_skill_md_gracefully(self): - """Test that sync handles SKILL.md with malformed frontmatter gracefully. + def test_sync_removes_malformed_skill_dirs(self): + """Test that sync removes orphaned skill directories with malformed SKILL.md. - If a SKILL.md has invalid YAML frontmatter, it is treated as a user-created - skill (no APM metadata found) and is NOT removed. This is the safe behavior - to prevent accidental data loss. + Uses npm-style approach: directory name matching, not SKILL.md content. + Malformed SKILL.md has no effect on orphan detection. """ # Create a skill with malformed frontmatter malformed_skill = self.project_root / ".github" / "skills" / "malformed" @@ -2631,29 +2542,19 @@ def test_sync_handles_malformed_skill_md_gracefully(self): result = self.integrator.sync_integration(apm_package, self.project_root) - # Skill should NOT be removed (treated as no APM metadata = user-created) - assert result['files_removed'] == 0 - assert malformed_skill.exists() + # Should be removed (not in installed set) + assert result['files_removed'] == 1 + assert not malformed_skill.exists() def test_sync_removes_orphans_only_from_github_when_no_claude(self): - """Test cleanup works correctly when .claude/ directory doesn't exist. - - When .claude/ doesn't exist, only .github/skills/ should be cleaned. - """ + """Test cleanup works correctly when .claude/ directory doesn't exist.""" # Ensure .claude/ does NOT exist assert not (self.project_root / ".claude").exists() - # Create an APM-managed orphan skill in .github/skills/ + # Create an orphan skill in .github/skills/ orphan_skill = self.project_root / ".github" / "skills" / "orphan" orphan_skill.mkdir(parents=True) - (orphan_skill / "SKILL.md").write_text("""--- -name: orphan -metadata: - apm_package: owner/orphan-pkg - apm_version: '1.0.0' ---- -# Orphan Skill -""") + (orphan_skill / "SKILL.md").write_text("# Orphan Skill\n") apm_package = Mock() apm_package.get_apm_dependencies.return_value = [] @@ -2665,35 +2566,19 @@ def test_sync_removes_orphans_only_from_github_when_no_claude(self): assert not orphan_skill.exists() def test_sync_aggregates_stats_from_both_locations(self): - """Test that sync correctly aggregates removal stats from both locations. - - When orphans exist in both .github/skills/ and .claude/skills/, - the stats should reflect total removals from both locations. - """ + """Test that sync correctly aggregates removal stats from both locations.""" # Create .claude/ directory (self.project_root / ".claude").mkdir() # Create orphan in .github/skills/ github_orphan = self.project_root / ".github" / "skills" / "orphan-a" github_orphan.mkdir(parents=True) - (github_orphan / "SKILL.md").write_text("""--- -name: orphan-a -metadata: - apm_package: owner/orphan-a ---- -# Orphan A -""") + (github_orphan / "SKILL.md").write_text("# Orphan A\n") # Create different orphan in .claude/skills/ claude_orphan = self.project_root / ".claude" / "skills" / "orphan-b" claude_orphan.mkdir(parents=True) - (claude_orphan / "SKILL.md").write_text("""--- -name: orphan-b -metadata: - apm_package: owner/orphan-b ---- -# Orphan B -""") + (claude_orphan / "SKILL.md").write_text("# Orphan B\n") apm_package = Mock() apm_package.get_apm_dependencies.return_value = [] diff --git a/tests/unit/integration/test_sync_integration_url_normalization.py b/tests/unit/integration/test_sync_integration_url_normalization.py index 0f0a8b6b2..068f66f64 100644 --- a/tests/unit/integration/test_sync_integration_url_normalization.py +++ b/tests/unit/integration/test_sync_integration_url_normalization.py @@ -1,18 +1,7 @@ -"""Tests for sync_integration URL normalization fix. +"""Tests for sync_integration nuke-and-regenerate behavior. -This test file specifically covers the critical bug fix where sync_integration -was incorrectly removing ALL integrated files instead of only orphaned ones. - -The bug was caused by URL format mismatch: -- Metadata stored: https://github.com/owner/repo (full URL) -- Dependency list: owner/repo (short form) -- Comparison failed, causing all files to be seen as orphans - -These tests ensure the URL normalization logic works correctly across: -- GitHub repositories -- Virtual packages -- Multiple packages installed simultaneously -- Different URL formats (with/without .git suffix) +These tests verify that sync_integration correctly removes all APM-managed +files (identified by the -apm suffix) for clean regeneration from the lockfile. """ import tempfile @@ -20,7 +9,6 @@ from unittest.mock import Mock from apm_cli.integration import PromptIntegrator, AgentIntegrator -from apm_cli.models.apm_package import DependencyReference class TestSyncIntegrationURLNormalization: @@ -38,301 +26,107 @@ def teardown_method(self): import shutil shutil.rmtree(self.temp_dir, ignore_errors=True) - def test_sync_removes_only_uninstalled_package_prompts(self): - """Test that uninstalling one package only removes its prompts, not others.""" + def test_sync_removes_all_apm_prompt_files(self): + """Test that sync removes all *-apm.prompt.md files (nuke approach).""" github_prompts = self.project_root / ".github" / "prompts" github_prompts.mkdir(parents=True) - # Create integrated prompts from multiple packages with YAML frontmatter - compliance_prompt = """--- -apm: - source: compliance-rules - source_repo: https://github.com/danielmeppiel/compliance-rules - version: 1.0.0 - commit: abc123 - original_path: compliance-audit.prompt.md - installed_at: '2024-11-13T10:00:00' - content_hash: hash1 ---- - -# Compliance Audit""" - - design_prompt = """--- -apm: - source: design-guidelines - source_repo: https://github.com/danielmeppiel/design-guidelines - version: 1.0.0 - commit: def456 - original_path: design-review.prompt.md - installed_at: '2024-11-13T10:00:00' - content_hash: hash2 ---- - -# Design Review""" + # Create integrated prompts from multiple packages + (github_prompts / "compliance-audit-apm.prompt.md").write_text("# Compliance Audit") + (github_prompts / "design-review-apm.prompt.md").write_text("# Design Review") + (github_prompts / "breakdown-plan-apm.prompt.md").write_text("# Breakdown Plan") - virtual_prompt = """--- -apm: - source: awesome-copilot-breakdown-plan - source_repo: https://github.com/github/awesome-copilot - version: 1.0.0 - commit: unknown - original_path: .apm/prompts/breakdown-plan.prompt.md - installed_at: '2024-11-13T10:00:00' - content_hash: hash3 ---- - -# Breakdown Plan""" - - (github_prompts / "compliance-audit-apm.prompt.md").write_text(compliance_prompt) - (github_prompts / "design-review-apm.prompt.md").write_text(design_prompt) - (github_prompts / "breakdown-plan-apm.prompt.md").write_text(virtual_prompt) - - # Simulate uninstalling design-guidelines (keeping compliance-rules and virtual package) apm_package = Mock() - apm_package.get_apm_dependencies.return_value = [ - DependencyReference( - repo_url="danielmeppiel/compliance-rules", - reference="main" - ), - DependencyReference( - repo_url="github/awesome-copilot", - reference="main" - ) - ] - # Run sync + # Run sync - nuke approach removes all result = self.prompt_integrator.sync_integration(apm_package, self.project_root) - # Verify only design-guidelines prompt was removed - assert not (github_prompts / "design-review-apm.prompt.md").exists(), "design-guidelines prompt should be removed" - assert (github_prompts / "compliance-audit-apm.prompt.md").exists(), "compliance-rules prompt should remain" - assert (github_prompts / "breakdown-plan-apm.prompt.md").exists(), "virtual package prompt should remain" - assert result['files_removed'] == 1, "Should remove exactly 1 file" - assert result['errors'] == 0, "Should have no errors" + assert not (github_prompts / "design-review-apm.prompt.md").exists() + assert not (github_prompts / "compliance-audit-apm.prompt.md").exists() + assert not (github_prompts / "breakdown-plan-apm.prompt.md").exists() + assert result['files_removed'] == 3 + assert result['errors'] == 0 - def test_sync_handles_github_url_formats(self): - """Test that sync correctly normalizes different GitHub URL formats.""" + def test_sync_preserves_non_apm_prompt_files(self): + """Test that sync only removes *-apm.prompt.md files, not other files.""" github_prompts = self.project_root / ".github" / "prompts" github_prompts.mkdir(parents=True) - # Test various URL formats in metadata - test_cases = [ - ("https://github.com/owner/repo", "owner/repo"), - ("https://github.com/owner/repo.git", "owner/repo"), - ("https://gitlab.com/owner/repo", "owner/repo"), - ("https://git.company.com/owner/repo", "owner/repo"), - ] + # APM files (should be removed) + (github_prompts / "test-apm.prompt.md").write_text("# APM prompt") - for idx, (source_repo_url, expected_match) in enumerate(test_cases): - prompt_content = f"""--- -apm: - source: test-package-{idx} - source_repo: {source_repo_url} - version: 1.0.0 - commit: abc123 - original_path: test.prompt.md - installed_at: '2024-11-13T10:00:00' - content_hash: hash{idx} ---- - -# Test Prompt {idx}""" - - (github_prompts / f"test-{idx}-apm.prompt.md").write_text(prompt_content) + # Non-APM files (should be preserved) + (github_prompts / "my-custom.prompt.md").write_text("# Custom prompt") + (github_prompts / "readme.md").write_text("# Readme") - # Simulate package still installed (short form) apm_package = Mock() - apm_package.get_apm_dependencies.return_value = [ - DependencyReference(repo_url="owner/repo", reference="main") - ] - # Run sync result = self.prompt_integrator.sync_integration(apm_package, self.project_root) - # All prompts should remain (they all normalize to owner/repo) - assert result['files_removed'] == 0, "No files should be removed - all should match" - for idx in range(len(test_cases)): - assert (github_prompts / f"test-{idx}-apm.prompt.md").exists(), f"Prompt {idx} should still exist" + assert result['files_removed'] == 1 + assert not (github_prompts / "test-apm.prompt.md").exists() + assert (github_prompts / "my-custom.prompt.md").exists() + assert (github_prompts / "readme.md").exists() - def test_sync_removes_only_uninstalled_package_agents(self): - """Test that uninstalling one package only removes its agents, not others.""" + def test_sync_nuke_removes_all_agent_files(self): + """Test that sync removes ALL *-apm.agent.md files (nuke-and-regenerate).""" github_agents = self.project_root / ".github" / "agents" github_agents.mkdir(parents=True) - # Create integrated agents from multiple packages with YAML frontmatter - compliance_agent = """--- -apm: - source: compliance-rules - source_repo: https://github.com/danielmeppiel/compliance-rules - version: 1.0.0 - commit: abc123 - original_path: compliance-agent.agent.md - installed_at: '2024-11-13T10:00:00' - content_hash: hash1 ---- - -# Compliance Agent""" - - design_agent = """--- -apm: - source: design-guidelines - source_repo: https://github.com/danielmeppiel/design-guidelines - version: 1.0.0 - commit: def456 - original_path: design-agent.agent.md - installed_at: '2024-11-13T10:00:00' - content_hash: hash2 ---- - -# Design Agent""" + # Create integrated agents from multiple packages + (github_agents / "compliance-agent-apm.agent.md").write_text("# Compliance Agent") + (github_agents / "design-agent-apm.agent.md").write_text("# Design Agent") + # Non-APM file should survive + (github_agents / "my-custom.agent.md").write_text("# My Custom Agent") - (github_agents / "compliance-agent-apm.agent.md").write_text(compliance_agent) - (github_agents / "design-agent-apm.agent.md").write_text(design_agent) - - # Simulate uninstalling design-guidelines (keeping compliance-rules) apm_package = Mock() - apm_package.get_apm_dependencies.return_value = [ - DependencyReference( - repo_url="danielmeppiel/compliance-rules", - reference="main" - ) - ] + apm_package.get_apm_dependencies.return_value = [] # Run sync result = self.agent_integrator.sync_integration(apm_package, self.project_root) - # Verify only design-guidelines agent was removed - assert not (github_agents / "design-agent-apm.agent.md").exists(), "design-guidelines agent should be removed" - assert (github_agents / "compliance-agent-apm.agent.md").exists(), "compliance-rules agent should remain" - assert result['files_removed'] == 1, "Should remove exactly 1 file" - assert result['errors'] == 0, "Should have no errors" + # All -apm files removed + assert not (github_agents / "compliance-agent-apm.agent.md").exists() + assert not (github_agents / "design-agent-apm.agent.md").exists() + # Non-APM file preserved + assert (github_agents / "my-custom.agent.md").exists() + assert result['files_removed'] == 2 + assert result['errors'] == 0 - def test_sync_with_three_packages_removes_one(self): - """Test realistic scenario: 3 packages installed, uninstall 1, verify 2 remain.""" + def test_sync_nuke_removes_all_prompt_files(self): + """Test that sync removes all *-apm.prompt.md files regardless of packages.""" github_prompts = self.project_root / ".github" / "prompts" github_prompts.mkdir(parents=True) # Create prompts from 3 packages - packages = [ - ("pkg-a", "https://github.com/owner/pkg-a"), - ("pkg-b", "https://github.com/owner/pkg-b"), - ("pkg-c", "https://github.com/owner/pkg-c"), - ] - - for pkg_name, repo_url in packages: - prompt = f"""--- -apm: - source: {pkg_name} - source_repo: {repo_url} - version: 1.0.0 - commit: abc123 - original_path: test.prompt.md - installed_at: '2024-11-13T10:00:00' - content_hash: hash ---- - -# Prompt from {pkg_name}""" - (github_prompts / f"{pkg_name}-apm.prompt.md").write_text(prompt) + packages = ["pkg-a", "pkg-b", "pkg-c"] + for pkg_name in packages: + (github_prompts / f"{pkg_name}-apm.prompt.md").write_text(f"# Prompt from {pkg_name}") - # Uninstall pkg-b (keep pkg-a and pkg-c) apm_package = Mock() - apm_package.get_apm_dependencies.return_value = [ - DependencyReference(repo_url="owner/pkg-a", reference="main"), - DependencyReference(repo_url="owner/pkg-c", reference="main") - ] - # Run sync result = self.prompt_integrator.sync_integration(apm_package, self.project_root) - # Verify correct removal - assert (github_prompts / "pkg-a-apm.prompt.md").exists(), "pkg-a should remain" - assert not (github_prompts / "pkg-b-apm.prompt.md").exists(), "pkg-b should be removed" - assert (github_prompts / "pkg-c-apm.prompt.md").exists(), "pkg-c should remain" - assert result['files_removed'] == 1, "Should remove exactly pkg-b" + # All APM files removed (nuke approach) + for pkg_name in packages: + assert not (github_prompts / f"{pkg_name}-apm.prompt.md").exists() + assert result['files_removed'] == 3 - def test_sync_preserves_files_without_metadata(self): - """Test that sync doesn't remove user's custom files without APM metadata.""" + def test_sync_nuke_preserves_non_apm_files(self): + """Test that nuke approach doesn't remove non-APM files.""" github_prompts = self.project_root / ".github" / "prompts" github_prompts.mkdir(parents=True) - # Create a user's custom prompt without APM metadata - custom_prompt = """# Custom User Prompt - -This is a custom prompt without APM metadata.""" - (github_prompts / "my-custom-apm.prompt.md").write_text(custom_prompt) + # User's custom prompt (no -apm suffix) + (github_prompts / "my-custom.prompt.md").write_text("# Custom prompt") - # Create an APM-integrated prompt - apm_prompt = """--- -apm: - source: test-package - source_repo: https://github.com/owner/test-package - version: 1.0.0 - commit: abc123 - original_path: test.prompt.md - installed_at: '2024-11-13T10:00:00' - content_hash: hash ---- - -# APM Prompt""" - (github_prompts / "test-apm.prompt.md").write_text(apm_prompt) + # APM-integrated prompt + (github_prompts / "test-apm.prompt.md").write_text("# APM Prompt") - # Uninstall the package (no packages remain) apm_package = Mock() - apm_package.get_apm_dependencies.return_value = [] - # Run sync result = self.prompt_integrator.sync_integration(apm_package, self.project_root) - # User's custom file should remain, APM file should be removed - assert (github_prompts / "my-custom-apm.prompt.md").exists(), "Custom file should remain" + assert (github_prompts / "my-custom.prompt.md").exists(), "Custom file should remain" assert not (github_prompts / "test-apm.prompt.md").exists(), "APM file should be removed" - assert result['files_removed'] == 1, "Should only remove the APM file" - - def test_sync_handles_virtual_packages_correctly(self): - """Test that virtual packages (single file imports) are handled correctly.""" - github_prompts = self.project_root / ".github" / "prompts" - github_prompts.mkdir(parents=True) - - # Virtual package: github/awesome-copilot/prompts/breakdown-plan.prompt.md - # The source_repo will be the repo root, not the file path - virtual_prompt = """--- -apm: - source: awesome-copilot-breakdown-plan - source_repo: https://github.com/github/awesome-copilot - version: 1.0.0 - commit: unknown - original_path: .apm/prompts/breakdown-plan.prompt.md - installed_at: '2024-11-13T10:00:00' - content_hash: hash ---- - -# Breakdown Plan""" - (github_prompts / "breakdown-plan-apm.prompt.md").write_text(virtual_prompt) - - # Regular package - regular_prompt = """--- -apm: - source: test-package - source_repo: https://github.com/owner/test-package - version: 1.0.0 - commit: abc123 - original_path: test.prompt.md - installed_at: '2024-11-13T10:00:00' - content_hash: hash ---- - -# Regular Prompt""" - (github_prompts / "test-apm.prompt.md").write_text(regular_prompt) - - # Keep only the virtual package - apm_package = Mock() - apm_package.get_apm_dependencies.return_value = [ - DependencyReference(repo_url="github/awesome-copilot", reference="main") - ] - - # Run sync - result = self.prompt_integrator.sync_integration(apm_package, self.project_root) - - # Virtual package should remain, regular should be removed - assert (github_prompts / "breakdown-plan-apm.prompt.md").exists(), "Virtual package prompt should remain" - assert not (github_prompts / "test-apm.prompt.md").exists(), "Regular package prompt should be removed" - assert result['files_removed'] == 1, "Should remove only the regular package" + assert result['files_removed'] == 1 diff --git a/tests/unit/test_init_command.py b/tests/unit/test_init_command.py index 739b13b6c..76ff3a7eb 100644 --- a/tests/unit/test_init_command.py +++ b/tests/unit/test_init_command.py @@ -248,88 +248,13 @@ def test_init_auto_detection(self): # Should auto-detect description assert "APM project" in config["description"] - def test_init_creates_skill_md(self): - """Test that init creates SKILL.md alongside apm.yml.""" + def test_init_does_not_create_skill_md(self): + """Test that init does not create SKILL.md (only apm.yml).""" with tempfile.TemporaryDirectory() as tmp_dir: os.chdir(tmp_dir) result = self.runner.invoke(cli, ["init", "--yes"]) assert result.exit_code == 0 - assert Path("SKILL.md").exists() - - def test_init_skill_md_contains_frontmatter(self): - """Test that SKILL.md contains proper YAML frontmatter.""" - with tempfile.TemporaryDirectory() as tmp_dir: - os.chdir(tmp_dir) - - result = self.runner.invoke(cli, ["init", "--yes"]) - - assert result.exit_code == 0 - - skill_content = Path("SKILL.md").read_text() - # Check frontmatter structure - assert skill_content.startswith("---") - assert "name:" in skill_content - assert "description:" in skill_content - # Frontmatter should be closed - assert skill_content.count("---") >= 2 - - def test_init_skill_md_reflects_project_name(self): - """Test that SKILL.md reflects the project name and description.""" - with tempfile.TemporaryDirectory() as tmp_dir: - os.chdir(tmp_dir) - - result = self.runner.invoke(cli, ["init", "my-awesome-package", "--yes"]) - - assert result.exit_code == 0 - - project_path = Path(tmp_dir) / "my-awesome-package" - skill_content = (project_path / "SKILL.md").read_text() - - # Check project name appears in frontmatter and title - assert "name: my-awesome-package" in skill_content - assert "# my-awesome-package" in skill_content - # Check install command uses project name - assert "apm install your-org/my-awesome-package" in skill_content - - def test_init_skill_md_has_expected_sections(self): - """Test that SKILL.md contains expected content sections.""" - with tempfile.TemporaryDirectory() as tmp_dir: - os.chdir(tmp_dir) - - result = self.runner.invoke(cli, ["init", "--yes"]) - - assert result.exit_code == 0 - - skill_content = Path("SKILL.md").read_text() - - # Check for expected sections - assert "## What This Package Does" in skill_content - assert "## Getting Started" in skill_content - assert "## Available Primitives" in skill_content - # Check primitive types are listed - assert "Instructions" in skill_content - assert "Prompts" in skill_content - assert "Agents" in skill_content - - def test_init_interactive_creates_skill_md_with_custom_values(self): - """Test that interactive mode creates SKILL.md with user-provided values.""" - with tempfile.TemporaryDirectory() as tmp_dir: - os.chdir(tmp_dir) - - # Simulate user input - user_input = "custom-project\n1.0.0\nMy custom description\nTest Author\ny\n" - - result = self.runner.invoke(cli, ["init"], input=user_input) - - assert result.exit_code == 0 - assert Path("SKILL.md").exists() - - skill_content = Path("SKILL.md").read_text() - - # Check custom values appear - assert "name: custom-project" in skill_content - assert "description: My custom description" in skill_content - assert "# custom-project" in skill_content - assert "My custom description" in skill_content + assert Path("apm.yml").exists() + assert not Path("SKILL.md").exists() diff --git a/tests/unit/test_orphan_detection.py b/tests/unit/test_orphan_detection.py index 1718a640e..64b1cc5ca 100644 --- a/tests/unit/test_orphan_detection.py +++ b/tests/unit/test_orphan_detection.py @@ -64,214 +64,148 @@ def create_mock_apm_package(dependencies: list) -> APMPackage: class TestAgentIntegratorOrphanDetection: - """Test orphan detection in AgentIntegrator with virtual packages.""" + """Test nuke-and-regenerate sync in AgentIntegrator. - def test_orphan_detection_regular_package(self): - """Regular package orphan detection (baseline).""" - with tempfile.TemporaryDirectory() as tmpdir: - project_root = Path(tmpdir) - agents_dir = project_root / ".github" / "agents" - - # Create an integrated agent from owner/repo - create_test_integrated_file( - agents_dir / "test-apm.agent.md", - source_repo="owner/repo", - source_dependency="owner/repo" - ) - - # Mock package with owner/repo installed - apm_package = create_mock_apm_package(["owner/repo"]) - - integrator = AgentIntegrator() - result = integrator.sync_integration(apm_package, project_root) - - # Should NOT be removed (package is installed) - assert result['files_removed'] == 0 - assert (agents_dir / "test-apm.agent.md").exists() + AgentIntegrator now uses nuke approach: removes ALL *-apm.agent.md + and *-apm.chatmode.md files. The caller re-integrates from installed packages. + """ - def test_orphan_detection_removes_uninstalled_package(self): - """Uninstalled package should be detected as orphan.""" + def test_sync_removes_all_apm_agent_files(self): + """All *-apm.agent.md files are removed regardless of install state.""" with tempfile.TemporaryDirectory() as tmpdir: project_root = Path(tmpdir) agents_dir = project_root / ".github" / "agents" - # Create an integrated agent from owner/repo create_test_integrated_file( agents_dir / "test-apm.agent.md", source_repo="owner/repo", source_dependency="owner/repo" ) - # Mock package with different package installed - apm_package = create_mock_apm_package(["other/package"]) + # Even with the package installed, nuke removes all + apm_package = create_mock_apm_package(["owner/repo"]) integrator = AgentIntegrator() result = integrator.sync_integration(apm_package, project_root) - # Should be removed (package not installed) assert result['files_removed'] == 1 assert not (agents_dir / "test-apm.agent.md").exists() - def test_orphan_detection_virtual_package_new_format(self): - """Virtual package with new format source_dependency should be matched correctly.""" + def test_sync_removes_multiple_apm_files(self): + """Multiple -apm files from different packages are all removed.""" with tempfile.TemporaryDirectory() as tmpdir: project_root = Path(tmpdir) agents_dir = project_root / ".github" / "agents" + agents_dir.mkdir(parents=True) - # Create agent from virtual collection - create_test_integrated_file( - agents_dir / "azure-apm.agent.md", - source_repo="github/awesome-copilot", - source_dependency="github/awesome-copilot/collections/azure-cloud-development" - ) + (agents_dir / "agent-a-apm.agent.md").write_text("# Agent A") + (agents_dir / "agent-b-apm.agent.md").write_text("# Agent B") + (agents_dir / "agent-c-apm.agent.md").write_text("# Agent C") - # Mock package with the virtual collection installed - apm_package = create_mock_apm_package([ - "github/awesome-copilot/collections/azure-cloud-development" - ]) + apm_package = create_mock_apm_package([]) integrator = AgentIntegrator() result = integrator.sync_integration(apm_package, project_root) - # Should NOT be removed (exact virtual package is installed) - assert result['files_removed'] == 0 - assert (agents_dir / "azure-apm.agent.md").exists() + assert result['files_removed'] == 3 - def test_orphan_detection_virtual_package_removed(self): - """When a virtual package is removed, its agents should be orphaned.""" + def test_sync_preserves_non_apm_files(self): + """Files without -apm suffix are preserved.""" with tempfile.TemporaryDirectory() as tmpdir: project_root = Path(tmpdir) agents_dir = project_root / ".github" / "agents" + agents_dir.mkdir(parents=True) - # Create agent from virtual collection - create_test_integrated_file( - agents_dir / "azure-apm.agent.md", - source_repo="github/awesome-copilot", - source_dependency="github/awesome-copilot/collections/azure-cloud-development" - ) + (agents_dir / "my-custom.agent.md").write_text("# Custom") + (agents_dir / "test-apm.agent.md").write_text("# APM managed") - # Mock package with a DIFFERENT virtual collection installed - apm_package = create_mock_apm_package([ - "github/awesome-copilot/collections/different-collection" - ]) + apm_package = create_mock_apm_package([]) integrator = AgentIntegrator() result = integrator.sync_integration(apm_package, project_root) - # Should be removed (different virtual package from same repo) assert result['files_removed'] == 1 - assert not (agents_dir / "azure-apm.agent.md").exists() - - def test_orphan_detection_old_format_fallback(self): - """Old format without source_dependency falls back to repo URL matching.""" - with tempfile.TemporaryDirectory() as tmpdir: - project_root = Path(tmpdir) - agents_dir = project_root / ".github" / "agents" - - # Create agent using old format (no source_dependency) - create_test_integrated_file( - agents_dir / "test-apm.agent.md", - source_repo="owner/repo", - source_dependency=None # Old format - ) - - # Mock package with owner/repo installed - apm_package = create_mock_apm_package(["owner/repo"]) - - integrator = AgentIntegrator() - result = integrator.sync_integration(apm_package, project_root) - - # Should NOT be removed (backwards compatibility) - assert result['files_removed'] == 0 - assert (agents_dir / "test-apm.agent.md").exists() + assert (agents_dir / "my-custom.agent.md").exists() + assert not (agents_dir / "test-apm.agent.md").exists() - def test_orphan_detection_chatmode_files(self): - """Legacy .chatmode.md files should also be cleaned up.""" + def test_sync_removes_chatmode_files(self): + """Legacy .chatmode.md files are also removed.""" with tempfile.TemporaryDirectory() as tmpdir: project_root = Path(tmpdir) agents_dir = project_root / ".github" / "agents" + agents_dir.mkdir(parents=True) - # Create a chatmode file - create_test_integrated_file( - agents_dir / "test-apm.chatmode.md", - source_repo="owner/repo", - source_dependency="owner/repo" - ) + (agents_dir / "test-apm.chatmode.md").write_text("# Chatmode") - # Mock package with nothing installed apm_package = create_mock_apm_package([]) integrator = AgentIntegrator() result = integrator.sync_integration(apm_package, project_root) - # Should be removed assert result['files_removed'] == 1 assert not (agents_dir / "test-apm.chatmode.md").exists() class TestPromptIntegratorOrphanDetection: - """Test orphan detection in PromptIntegrator with virtual packages.""" + """Test nuke-and-regenerate sync in PromptIntegrator. + + PromptIntegrator now uses nuke approach: removes ALL *-apm.prompt.md files. + The caller re-integrates from currently installed packages. + """ - def test_orphan_detection_regular_package(self): - """Regular package orphan detection (baseline).""" + def test_sync_removes_all_apm_files(self): + """All *-apm.prompt.md files are removed regardless of metadata.""" with tempfile.TemporaryDirectory() as tmpdir: project_root = Path(tmpdir) prompts_dir = project_root / ".github" / "prompts" - # Create an integrated prompt from owner/repo create_test_integrated_file( prompts_dir / "test-apm.prompt.md", source_repo="owner/repo", source_dependency="owner/repo" ) - # Mock package with owner/repo installed apm_package = create_mock_apm_package(["owner/repo"]) integrator = PromptIntegrator() result = integrator.sync_integration(apm_package, project_root) - # Should NOT be removed (package is installed) - assert result['files_removed'] == 0 - assert (prompts_dir / "test-apm.prompt.md").exists() + # Nuke removes ALL apm files + assert result['files_removed'] == 1 + assert not (prompts_dir / "test-apm.prompt.md").exists() - def test_orphan_detection_removes_uninstalled_package(self): - """Uninstalled package should be detected as orphan.""" + def test_sync_removes_uninstalled_package(self): + """Uninstalled package files are removed.""" with tempfile.TemporaryDirectory() as tmpdir: project_root = Path(tmpdir) prompts_dir = project_root / ".github" / "prompts" - # Create an integrated prompt from owner/repo create_test_integrated_file( prompts_dir / "test-apm.prompt.md", source_repo="owner/repo", source_dependency="owner/repo" ) - # Mock package with different package installed apm_package = create_mock_apm_package(["other/package"]) integrator = PromptIntegrator() result = integrator.sync_integration(apm_package, project_root) - # Should be removed (package not installed) assert result['files_removed'] == 1 assert not (prompts_dir / "test-apm.prompt.md").exists() - def test_orphan_detection_virtual_package_new_format(self): - """Virtual package with new format source_dependency should be matched correctly.""" + def test_sync_removes_virtual_package_files(self): + """Virtual package files are also removed by nuke approach.""" with tempfile.TemporaryDirectory() as tmpdir: project_root = Path(tmpdir) prompts_dir = project_root / ".github" / "prompts" - # Create prompt from virtual file create_test_integrated_file( prompts_dir / "code-review-apm.prompt.md", source_repo="github/awesome-copilot", source_dependency="github/awesome-copilot/prompts/code-review.prompt.md" ) - # Mock package with the virtual file installed apm_package = create_mock_apm_package([ "github/awesome-copilot/prompts/code-review.prompt.md" ]) @@ -279,64 +213,41 @@ def test_orphan_detection_virtual_package_new_format(self): integrator = PromptIntegrator() result = integrator.sync_integration(apm_package, project_root) - # Should NOT be removed (exact virtual package is installed) - assert result['files_removed'] == 0 - assert (prompts_dir / "code-review-apm.prompt.md").exists() - - def test_orphan_detection_virtual_package_removed(self): - """When a virtual package is removed, its prompts should be orphaned.""" - with tempfile.TemporaryDirectory() as tmpdir: - project_root = Path(tmpdir) - prompts_dir = project_root / ".github" / "prompts" - - # Create prompt from virtual file - create_test_integrated_file( - prompts_dir / "code-review-apm.prompt.md", - source_repo="github/awesome-copilot", - source_dependency="github/awesome-copilot/prompts/code-review.prompt.md" - ) - - # Mock package with a DIFFERENT virtual file installed - apm_package = create_mock_apm_package([ - "github/awesome-copilot/prompts/other-file.prompt.md" - ]) - - integrator = PromptIntegrator() - result = integrator.sync_integration(apm_package, project_root) - - # Should be removed (different virtual package from same repo) + # Nuke removes everything assert result['files_removed'] == 1 assert not (prompts_dir / "code-review-apm.prompt.md").exists() - def test_orphan_detection_old_format_fallback(self): - """Old format without source_dependency falls back to repo URL matching.""" + def test_sync_preserves_non_apm_suffix_files(self): + """Files without -apm suffix are not removed.""" with tempfile.TemporaryDirectory() as tmpdir: project_root = Path(tmpdir) prompts_dir = project_root / ".github" / "prompts" + prompts_dir.mkdir(parents=True, exist_ok=True) - # Create prompt using old format (no source_dependency) + # Non-APM file + (prompts_dir / "custom.prompt.md").write_text("# Custom") + # APM file create_test_integrated_file( prompts_dir / "test-apm.prompt.md", source_repo="owner/repo", - source_dependency=None # Old format + source_dependency="owner/repo" ) - # Mock package with owner/repo installed apm_package = create_mock_apm_package(["owner/repo"]) integrator = PromptIntegrator() result = integrator.sync_integration(apm_package, project_root) - # Should NOT be removed (backwards compatibility) - assert result['files_removed'] == 0 - assert (prompts_dir / "test-apm.prompt.md").exists() + assert result['files_removed'] == 1 + assert not (prompts_dir / "test-apm.prompt.md").exists() + assert (prompts_dir / "custom.prompt.md").exists() class TestMixedScenarios: """Test complex scenarios with multiple packages and virtual packages.""" def test_multiple_virtual_packages_from_same_repo(self): - """Multiple virtual packages from same repo handled correctly.""" + """Multiple virtual packages from same repo — nuke removes all.""" with tempfile.TemporaryDirectory() as tmpdir: project_root = Path(tmpdir) agents_dir = project_root / ".github" / "agents" @@ -353,7 +264,6 @@ def test_multiple_virtual_packages_from_same_repo(self): source_dependency="github/awesome-copilot/collections/aws" ) - # Mock package with only azure collection installed apm_package = create_mock_apm_package([ "github/awesome-copilot/collections/azure" ]) @@ -361,13 +271,13 @@ def test_multiple_virtual_packages_from_same_repo(self): integrator = AgentIntegrator() result = integrator.sync_integration(apm_package, project_root) - # Only aws should be removed - assert result['files_removed'] == 1 - assert (agents_dir / "azure-apm.agent.md").exists() + # Nuke removes ALL -apm files (caller re-integrates installed ones) + assert result['files_removed'] == 2 + assert not (agents_dir / "azure-apm.agent.md").exists() assert not (agents_dir / "aws-apm.agent.md").exists() def test_regular_and_virtual_packages_mixed(self): - """Mix of regular and virtual packages handled correctly.""" + """Mix of regular and virtual packages handled correctly by nuke approach.""" with tempfile.TemporaryDirectory() as tmpdir: project_root = Path(tmpdir) prompts_dir = project_root / ".github" / "prompts" @@ -394,7 +304,7 @@ def test_regular_and_virtual_packages_mixed(self): integrator = PromptIntegrator() result = integrator.sync_integration(apm_package, project_root) - # Neither should be removed - assert result['files_removed'] == 0 - assert (prompts_dir / "regular-apm.prompt.md").exists() - assert (prompts_dir / "virtual-apm.prompt.md").exists() + # Nuke removes ALL apm files (caller re-integrates) + assert result['files_removed'] == 2 + assert not (prompts_dir / "regular-apm.prompt.md").exists() + assert not (prompts_dir / "virtual-apm.prompt.md").exists() diff --git a/tests/unit/test_uninstall_reintegration.py b/tests/unit/test_uninstall_reintegration.py new file mode 100644 index 000000000..5d44995b0 --- /dev/null +++ b/tests/unit/test_uninstall_reintegration.py @@ -0,0 +1,327 @@ +"""Tests for the uninstall nuke-and-regenerate flow. + +When a package is uninstalled, all -apm suffixed integrated files are nuked, +then remaining packages are re-integrated from apm_modules/. +""" + +import pytest +from pathlib import Path +from datetime import datetime + +from apm_cli.integration import PromptIntegrator, AgentIntegrator +from apm_cli.integration.skill_integrator import SkillIntegrator +from apm_cli.integration.command_integrator import CommandIntegrator +from apm_cli.models.apm_package import ( + PackageInfo, + APMPackage, + ResolvedReference, + GitReferenceType, + PackageType, + PackageContentType, +) + + +def _make_package( + tmp_path: Path, + owner: str, + name: str, + *, + prompts: dict[str, str] | None = None, + agents: dict[str, str] | None = None, + skill_md: str | None = None, + pkg_type: PackageContentType | None = None, +) -> PackageInfo: + """Create a minimal package under apm_modules//.""" + pkg_path = tmp_path / "apm_modules" / owner / name + pkg_path.mkdir(parents=True, exist_ok=True) + + type_line = f"\ntype: {pkg_type.value}" if pkg_type else "" + (pkg_path / "apm.yml").write_text( + f"name: {name}\nversion: 1.0.0{type_line}\n" + ) + + if prompts: + prompts_dir = pkg_path / ".apm" / "prompts" + prompts_dir.mkdir(parents=True, exist_ok=True) + for fname, content in prompts.items(): + (prompts_dir / fname).write_text(content) + + if agents: + agents_dir = pkg_path / ".apm" / "agents" + agents_dir.mkdir(parents=True, exist_ok=True) + for fname, content in agents.items(): + (agents_dir / fname).write_text(content) + + if skill_md is not None: + (pkg_path / "SKILL.md").write_text(skill_md) + + pkg = APMPackage( + name=name, + version="1.0.0", + package_path=pkg_path, + type=pkg_type, + ) + resolved_ref = ResolvedReference( + original_ref="main", + ref_type=GitReferenceType.BRANCH, + resolved_commit="abc123", + ref_name="main", + ) + package_type = PackageType.CLAUDE_SKILL if skill_md else PackageType.APM_PACKAGE + if skill_md and (prompts or agents): + package_type = PackageType.HYBRID + + return PackageInfo( + package=pkg, + install_path=pkg_path, + resolved_reference=resolved_ref, + installed_at=datetime.now().isoformat(), + package_type=package_type, + ) + + +# --------------------------------------------------------------------------- +# Prompt nuke-and-regenerate +# --------------------------------------------------------------------------- + + +class TestUninstallPreservesOtherPackagePrompts: + """Nuke all *-apm.prompt.md, re-integrate remaining → only remaining survives.""" + + def test_uninstall_preserves_other_package_prompts(self, tmp_path: Path): + project_root = tmp_path + (project_root / ".github").mkdir() + + # Two packages, each with a prompt + pkg_a = _make_package( + tmp_path, "owner", "pkg-a", + prompts={"review.prompt.md": "---\nname: review\n---\n# Review A"}, + ) + pkg_b = _make_package( + tmp_path, "owner", "pkg-b", + prompts={"lint.prompt.md": "---\nname: lint\n---\n# Lint B"}, + ) + + prompt_int = PromptIntegrator() + + # Integrate both + prompt_int.integrate_package_prompts(pkg_a, project_root) + prompt_int.integrate_package_prompts(pkg_b, project_root) + + prompts_dir = project_root / ".github" / "prompts" + assert (prompts_dir / "review-apm.prompt.md").exists() + assert (prompts_dir / "lint-apm.prompt.md").exists() + + # --- Simulate uninstall of pkg-a --- + # Phase 1: nuke all -apm prompt files + dummy_pkg = APMPackage(name="root", version="0.0.0") + prompt_int.sync_integration(dummy_pkg, project_root) + + # Everything nuked + assert not (prompts_dir / "review-apm.prompt.md").exists() + assert not (prompts_dir / "lint-apm.prompt.md").exists() + + # Phase 2: re-integrate only pkg-b + prompt_int.integrate_package_prompts(pkg_b, project_root) + + assert not (prompts_dir / "review-apm.prompt.md").exists() + assert (prompts_dir / "lint-apm.prompt.md").exists() + + +# --------------------------------------------------------------------------- +# Agent nuke-and-regenerate +# --------------------------------------------------------------------------- + + +class TestUninstallPreservesOtherPackageAgents: + """Nuke all *-apm.agent.md, re-integrate remaining → only remaining survives.""" + + def test_uninstall_preserves_other_package_agents(self, tmp_path: Path): + project_root = tmp_path + (project_root / ".github").mkdir() + + pkg_a = _make_package( + tmp_path, "owner", "pkg-a", + agents={"security.agent.md": "---\nname: security\n---\n# Security A"}, + ) + pkg_b = _make_package( + tmp_path, "owner", "pkg-b", + agents={"planner.agent.md": "---\nname: planner\n---\n# Planner B"}, + ) + + agent_int = AgentIntegrator() + + agent_int.integrate_package_agents(pkg_a, project_root) + agent_int.integrate_package_agents(pkg_b, project_root) + + agents_dir = project_root / ".github" / "agents" + assert (agents_dir / "security-apm.agent.md").exists() + assert (agents_dir / "planner-apm.agent.md").exists() + + # Phase 1: nuke + dummy_pkg = APMPackage(name="root", version="0.0.0") + agent_int.sync_integration(dummy_pkg, project_root) + + assert not (agents_dir / "security-apm.agent.md").exists() + assert not (agents_dir / "planner-apm.agent.md").exists() + + # Phase 2: re-integrate only pkg-b + agent_int.integrate_package_agents(pkg_b, project_root) + + assert not (agents_dir / "security-apm.agent.md").exists() + assert (agents_dir / "planner-apm.agent.md").exists() + + +# --------------------------------------------------------------------------- +# Skill name-based cleanup +# --------------------------------------------------------------------------- + + +class TestUninstallPreservesOtherPackageSkills: + """Skills use name-based matching: only the uninstalled skill dir is removed.""" + + def test_uninstall_preserves_other_package_skills(self, tmp_path: Path): + project_root = tmp_path + (project_root / ".github").mkdir() + + pkg_a = _make_package( + tmp_path, "owner", "skill-a", + skill_md="---\nname: skill-a\ndescription: test A\n---\n# Skill A", + pkg_type=PackageContentType.SKILL, + ) + pkg_b = _make_package( + tmp_path, "owner", "skill-b", + skill_md="---\nname: skill-b\ndescription: test B\n---\n# Skill B", + pkg_type=PackageContentType.SKILL, + ) + + skill_int = SkillIntegrator() + + skill_int.integrate_package_skill(pkg_a, project_root) + skill_int.integrate_package_skill(pkg_b, project_root) + + skills_dir = project_root / ".github" / "skills" + assert (skills_dir / "skill-a").is_dir() + assert (skills_dir / "skill-b").is_dir() + + # Build an APMPackage that only lists skill-b as a remaining dependency. + # sync_integration derives expected names from get_apm_dependencies(). + # We use a real APMPackage loaded from a manifest that references skill-b only. + remaining_manifest = tmp_path / "remaining_apm.yml" + remaining_manifest.write_text( + "name: root\nversion: 0.0.0\n" + "dependencies:\n" + " apm:\n" + " - owner/skill-b\n" + ) + root_pkg = APMPackage.from_apm_yml(remaining_manifest) + + skill_int.sync_integration(root_pkg, project_root) + + # skill-a removed, skill-b preserved + assert not (skills_dir / "skill-a").exists() + assert (skills_dir / "skill-b").is_dir() + + +# --------------------------------------------------------------------------- +# User files not touched +# --------------------------------------------------------------------------- + + +class TestUninstallPreservesUserFiles: + """Nuke only touches *-apm.* files; user-created files survive.""" + + def test_uninstall_preserves_user_files(self, tmp_path: Path): + project_root = tmp_path + (project_root / ".github").mkdir() + + # User-created prompt (no -apm suffix) + prompts_dir = project_root / ".github" / "prompts" + prompts_dir.mkdir(parents=True) + user_file = prompts_dir / "my-review.prompt.md" + user_file.write_text("# My custom review prompt") + + # User-created agent + agents_dir = project_root / ".github" / "agents" + agents_dir.mkdir(parents=True) + user_agent = agents_dir / "my-agent.agent.md" + user_agent.write_text("# My custom agent") + + # User-created command + commands_dir = project_root / ".claude" / "commands" + commands_dir.mkdir(parents=True) + user_cmd = commands_dir / "my-command.md" + user_cmd.write_text("# My custom command") + + # Also add an APM-managed file to confirm it gets nuked + (prompts_dir / "pkg-review-apm.prompt.md").write_text("# APM managed") + (agents_dir / "pkg-agent-apm.agent.md").write_text("# APM managed") + (commands_dir / "pkg-cmd-apm.md").write_text("# APM managed") + + dummy_pkg = APMPackage(name="root", version="0.0.0") + + PromptIntegrator().sync_integration(dummy_pkg, project_root) + AgentIntegrator().sync_integration(dummy_pkg, project_root) + CommandIntegrator().sync_integration(dummy_pkg, project_root) + + # APM files gone + assert not (prompts_dir / "pkg-review-apm.prompt.md").exists() + assert not (agents_dir / "pkg-agent-apm.agent.md").exists() + assert not (commands_dir / "pkg-cmd-apm.md").exists() + + # User files untouched + assert user_file.exists() + assert user_file.read_text() == "# My custom review prompt" + assert user_agent.exists() + assert user_agent.read_text() == "# My custom agent" + assert user_cmd.exists() + assert user_cmd.read_text() == "# My custom command" + + +# --------------------------------------------------------------------------- +# Last package uninstall → clean state +# --------------------------------------------------------------------------- + + +class TestUninstallLastPackageLeavesCleanDirs: + """Installing one package and uninstalling it removes all -apm artifacts.""" + + def test_uninstall_last_package_leaves_clean_dirs(self, tmp_path: Path): + project_root = tmp_path + (project_root / ".github").mkdir() + + pkg = _make_package( + tmp_path, "owner", "only-pkg", + prompts={"guide.prompt.md": "---\nname: guide\n---\n# Guide"}, + agents={"helper.agent.md": "---\nname: helper\n---\n# Helper"}, + ) + + prompt_int = PromptIntegrator() + agent_int = AgentIntegrator() + cmd_int = CommandIntegrator() + + prompt_int.integrate_package_prompts(pkg, project_root) + agent_int.integrate_package_agents(pkg, project_root) + cmd_int.integrate_package_commands(pkg, project_root) + + prompts_dir = project_root / ".github" / "prompts" + agents_dir = project_root / ".github" / "agents" + commands_dir = project_root / ".claude" / "commands" + + # Verify files were created + apm_prompts = list(prompts_dir.glob("*-apm.prompt.md")) + apm_agents = list(agents_dir.glob("*-apm.agent.md")) + apm_commands = list(commands_dir.glob("*-apm.md")) + assert len(apm_prompts) > 0 + assert len(apm_agents) > 0 + assert len(apm_commands) > 0 + + # Nuke everything (no re-integration — last package removed) + dummy_pkg = APMPackage(name="root", version="0.0.0") + prompt_int.sync_integration(dummy_pkg, project_root) + agent_int.sync_integration(dummy_pkg, project_root) + cmd_int.sync_integration(dummy_pkg, project_root) + + assert list(prompts_dir.glob("*-apm.prompt.md")) == [] + assert list(agents_dir.glob("*-apm.agent.md")) == [] + assert list(commands_dir.glob("*-apm.md")) == [] diff --git a/uv.lock b/uv.lock index c33de663f..f2830ec0c 100644 --- a/uv.lock +++ b/uv.lock @@ -166,7 +166,7 @@ wheels = [ [[package]] name = "apm-cli" -version = "0.7.1" +version = "0.7.2" source = { editable = "." } dependencies = [ { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },