From d92a3d08405e2f840a4895692f4816f5fae54a79 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 22:01:24 +0000 Subject: [PATCH 1/5] Initial plan From 810d87b2af77b05a3b82cc6e076b053835d6adc3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 22:08:18 +0000 Subject: [PATCH 2/5] feat: deploy agents to .claude/agents/ during install for Claude Code target Add integrate_package_agents_claude() to AgentIntegrator that deploys .agent.md and .chatmode.md files to .claude/agents/ as .md files. Update install flow to call Claude agent integration when integrate_claude is True. Add sync_integration_claude() for cleanup. Add gitignore support. Include 16 new unit tests for Claude agent integration. Co-authored-by: danielmeppiel <51440732+danielmeppiel@users.noreply.github.com> --- src/apm_cli/cli.py | 54 +++- src/apm_cli/core/target_detection.py | 2 +- src/apm_cli/integration/agent_integrator.py | 152 ++++++++++++ .../unit/integration/test_agent_integrator.py | 231 ++++++++++++++++++ 4 files changed, 436 insertions(+), 3 deletions(-) diff --git a/src/apm_cli/cli.py b/src/apm_cli/cli.py index ea8dbb5c8..8aae937db 100644 --- a/src/apm_cli/cli.py +++ b/src/apm_cli/cli.py @@ -1328,6 +1328,11 @@ def _find_transitive_orphans(lockfile, removed_urls): result = integrator.sync_integration(apm_package, project_root) agents_cleaned = result.get("files_removed", 0) + if Path(".claude/agents").exists(): + integrator = AgentIntegrator() + result = integrator.sync_integration_claude(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) @@ -1372,6 +1377,7 @@ def _find_transitive_orphans(lockfile, removed_urls): 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) + agent_integrator.integrate_package_agents_claude(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) @@ -1820,8 +1826,24 @@ def matches_filter(dep): f" └─ {instruction_count} instruction(s) ready (compile via `apm compile`)" ) - # Claude-specific integration (commands) + # Claude-specific integration (agents + commands) if integrate_claude: + # Integrate agents to .claude/agents/ + claude_agent_result = ( + agent_integrator.integrate_package_agents_claude( + cached_package_info, project_root + ) + ) + if claude_agent_result.files_integrated > 0: + total_agents_integrated += ( + claude_agent_result.files_integrated + ) + _rich_info( + f" └─ {claude_agent_result.files_integrated} agents integrated → .claude/agents/" + ) + total_links_resolved += claude_agent_result.links_resolved + + # Generate Claude commands from prompts command_result = ( command_integrator.integrate_package_commands( cached_package_info, project_root @@ -1989,8 +2011,23 @@ def matches_filter(dep): f" └─ {instruction_count} instruction(s) ready (compile via `apm compile`)" ) - # Claude-specific integration (commands) + # Claude-specific integration (agents + commands) if integrate_claude: + # Integrate agents to .claude/agents/ + claude_agent_result = ( + agent_integrator.integrate_package_agents_claude( + package_info, project_root + ) + ) + if claude_agent_result.files_integrated > 0: + total_agents_integrated += ( + claude_agent_result.files_integrated + ) + _rich_info( + f" └─ {claude_agent_result.files_integrated} agents integrated → .claude/agents/" + ) + total_links_resolved += claude_agent_result.links_resolved + # Generate Claude commands from prompts command_result = ( command_integrator.integrate_package_commands( @@ -2064,6 +2101,19 @@ def matches_filter(dep): if gitignore_updated: _rich_info("Updated .gitignore for integrated primitives") + # Update .gitignore for integrated Claude agents if any were integrated + if integrate_claude and total_agents_integrated > 0: + try: + updated = agent_integrator.update_gitignore_for_integrated_agents_claude( + project_root + ) + if updated: + _rich_info( + "Updated .gitignore for integrated Claude agents (*-apm.md)" + ) + except Exception as e: + _rich_warning(f"Could not update .gitignore for Claude agents: {e}") + # Show link resolution stats if any were resolved if total_links_resolved > 0: _rich_info(f"✓ Resolved {total_links_resolved} context file links") diff --git a/src/apm_cli/core/target_detection.py b/src/apm_cli/core/target_detection.py index c01d20333..cd7190ab7 100644 --- a/src/apm_cli/core/target_detection.py +++ b/src/apm_cli/core/target_detection.py @@ -133,7 +133,7 @@ def get_target_description(target: TargetType) -> str: """ descriptions = { "vscode": "AGENTS.md + .github/prompts/ + .github/agents/", - "claude": "CLAUDE.md + .claude/commands/ + SKILL.md", + "claude": "CLAUDE.md + .claude/commands/ + .claude/agents/ + .claude/skills/", "all": "AGENTS.md + CLAUDE.md + .github/ + .claude/", "minimal": "AGENTS.md only (create .github/ or .claude/ for full integration)", } diff --git a/src/apm_cli/integration/agent_integrator.py b/src/apm_cli/integration/agent_integrator.py index 739905c49..0211259cf 100644 --- a/src/apm_cli/integration/agent_integrator.py +++ b/src/apm_cli/integration/agent_integrator.py @@ -229,6 +229,88 @@ def integrate_package_agents(self, package_info, project_root: Path) -> Integrat links_resolved=total_links_resolved ) + def get_target_filename_claude(self, source_file: Path, package_name: str) -> str: + """Generate target filename for Claude agents with -apm suffix. + + Claude sub-agents use plain .md files in .claude/agents/. + Both .agent.md and .chatmode.md sources are converted to .md. + + Args: + source_file: Source file path + package_name: Name of the package (not used in simple naming) + + Returns: + str: Target filename with -apm.md suffix (e.g., security-apm.md) + """ + if source_file.name.endswith('.agent.md'): + stem = source_file.name[:-9] # Remove .agent.md + elif source_file.name.endswith('.chatmode.md'): + stem = source_file.name[:-12] # Remove .chatmode.md + else: + stem = source_file.stem + + return f"{stem}-apm.md" + + def integrate_package_agents_claude(self, package_info, project_root: Path) -> IntegrationResult: + """Integrate all agents from a package into .claude/agents/. + + Deploys agent files to Claude Code's native sub-agent directory. + Always overwrites existing files. Resolves context links during integration. + + Args: + package_info: PackageInfo object with package metadata + project_root: Root directory of the project + + Returns: + IntegrationResult: Results of the integration operation + """ + # Initialize link resolver and register contexts + self.link_resolver = UnifiedLinkResolver(project_root) + try: + primitives = discover_primitives(package_info.install_path) + self.link_resolver.register_contexts(primitives) + except Exception: + self.link_resolver = None + + # Find all agent files in the package + agent_files = self.find_agent_files(package_info.install_path) + + if not agent_files: + return IntegrationResult( + files_integrated=0, + files_updated=0, + files_skipped=0, + target_paths=[], + gitignore_updated=False + ) + + # Create .claude/agents/ if it doesn't exist + agents_dir = project_root / ".claude" / "agents" + agents_dir.mkdir(parents=True, exist_ok=True) + + # Process each agent file — always overwrite + files_integrated = 0 + target_paths = [] + total_links_resolved = 0 + + for source_file in agent_files: + target_filename = self.get_target_filename_claude(source_file, package_info.package.name) + target_path = agents_dir / target_filename + + 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=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]: """Remove all APM-managed agent files for clean regeneration. @@ -257,6 +339,31 @@ def sync_integration(self, apm_package, project_root: Path) -> Dict[str, int]: return stats + def sync_integration_claude(self, apm_package, project_root: Path) -> Dict[str, int]: + """Remove all APM-managed agent files from .claude/agents/ for clean regeneration. + + Args: + 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 / ".claude" / "agents" + if not agents_dir.exists(): + return stats + + for agent_file in agents_dir.glob("*-apm.md"): + try: + agent_file.unlink() + stats['files_removed'] += 1 + except Exception: + stats['errors'] += 1 + + return stats + def update_gitignore_for_integrated_agents(self, project_root: Path) -> bool: """Update .gitignore with pattern for integrated agents. @@ -306,3 +413,48 @@ def update_gitignore_for_integrated_agents(self, project_root: Path) -> bool: return True except Exception: return False + + def update_gitignore_for_integrated_agents_claude(self, project_root: Path) -> bool: + """Update .gitignore with pattern for Claude integrated agents. + + Args: + project_root: Root directory of the project + + Returns: + bool: True if .gitignore was updated, False if pattern already exists + """ + gitignore_path = project_root / ".gitignore" + + patterns = [ + ".claude/agents/*-apm.md" + ] + + # Read current content + current_content = [] + if gitignore_path.exists(): + try: + with open(gitignore_path, "r", encoding="utf-8") as f: + current_content = [line.rstrip("\n\r") for line in f.readlines()] + except Exception: + return False + + # Check which patterns need to be added + patterns_to_add = [] + for pattern in patterns: + if not any(pattern in line for line in current_content): + patterns_to_add.append(pattern) + + if not patterns_to_add: + return False + + # Add patterns to .gitignore + try: + with open(gitignore_path, "a", encoding="utf-8") as f: + if current_content and current_content[-1].strip(): + f.write("\n") + f.write("\n# APM integrated Claude agents\n") + for pattern in patterns_to_add: + f.write(f"{pattern}\n") + return True + except Exception: + return False diff --git a/tests/unit/integration/test_agent_integrator.py b/tests/unit/integration/test_agent_integrator.py index 7994ad512..6d35932bf 100644 --- a/tests/unit/integration/test_agent_integrator.py +++ b/tests/unit/integration/test_agent_integrator.py @@ -584,3 +584,234 @@ def test_gitignore_pattern_matches_suffix_files(self): assert not fnmatch.fnmatch("security.agent.md", agent_pattern) assert not fnmatch.fnmatch("apm.agent.md", agent_pattern) assert not fnmatch.fnmatch("default.chatmode.md", chatmode_pattern) + + +class TestClaudeAgentIntegration: + """Tests for Claude agent integration (.claude/agents/).""" + + def setup_method(self): + """Set up test fixtures.""" + self.temp_dir = tempfile.mkdtemp() + self.project_root = Path(self.temp_dir) + self.integrator = AgentIntegrator() + + def teardown_method(self): + """Clean up after tests.""" + import shutil + shutil.rmtree(self.temp_dir, ignore_errors=True) + + def _create_package_info(self, package_dir): + """Helper to create a PackageInfo object.""" + package = APMPackage( + name="test-pkg", + version="1.0.0", + package_path=package_dir + ) + resolved_ref = ResolvedReference( + original_ref="main", + ref_type=GitReferenceType.BRANCH, + resolved_commit="abc123", + ref_name="main" + ) + return PackageInfo( + package=package, + install_path=package_dir, + resolved_reference=resolved_ref, + installed_at=datetime.now().isoformat() + ) + + def test_get_target_filename_claude_from_agent_md(self): + """Test Claude filename from .agent.md uses .md extension.""" + source = Path("security.agent.md") + result = self.integrator.get_target_filename_claude(source, "pkg") + assert result == "security-apm.md" + + def test_get_target_filename_claude_from_chatmode_md(self): + """Test Claude filename from .chatmode.md uses .md extension.""" + source = Path("default.chatmode.md") + result = self.integrator.get_target_filename_claude(source, "pkg") + assert result == "default-apm.md" + + def test_get_target_filename_claude_hyphenated(self): + """Test Claude filename with hyphenated source name.""" + source = Path("backend-engineer.agent.md") + result = self.integrator.get_target_filename_claude(source, "pkg") + assert result == "backend-engineer-apm.md" + + def test_integrate_creates_claude_agents_directory(self): + """Test that integration creates .claude/agents/ if missing.""" + package_dir = self.project_root / "package" + package_dir.mkdir() + (package_dir / "security.agent.md").write_text("# Security Agent") + + package_info = self._create_package_info(package_dir) + result = self.integrator.integrate_package_agents_claude(package_info, self.project_root) + + assert result.files_integrated == 1 + assert (self.project_root / ".claude" / "agents").exists() + + def test_integrate_copies_agent_to_claude_agents(self): + """Test agent files are copied to .claude/agents/ with .md extension.""" + package_dir = self.project_root / "package" + package_dir.mkdir() + (package_dir / "security.agent.md").write_text("# Security Agent\nReview code for vulnerabilities.") + + package_info = self._create_package_info(package_dir) + result = self.integrator.integrate_package_agents_claude(package_info, self.project_root) + + assert result.files_integrated == 1 + target_file = self.project_root / ".claude" / "agents" / "security-apm.md" + assert target_file.exists() + content = target_file.read_text() + assert "Security Agent" in content + assert "Review code for vulnerabilities" in content + + def test_integrate_handles_chatmode_files(self): + """Test .chatmode.md files are integrated to .claude/agents/.""" + package_dir = self.project_root / "package" + package_dir.mkdir() + (package_dir / "backend.chatmode.md").write_text("# Backend Mode") + + package_info = self._create_package_info(package_dir) + result = self.integrator.integrate_package_agents_claude(package_info, self.project_root) + + assert result.files_integrated == 1 + target_file = self.project_root / ".claude" / "agents" / "backend-apm.md" + assert target_file.exists() + + def test_integrate_multiple_agents(self): + """Test multiple agent files are all integrated.""" + package_dir = self.project_root / "package" + package_dir.mkdir() + (package_dir / "security.agent.md").write_text("# Security") + (package_dir / "planner.agent.md").write_text("# Planner") + (package_dir / "default.chatmode.md").write_text("# Default") + + package_info = self._create_package_info(package_dir) + result = self.integrator.integrate_package_agents_claude(package_info, self.project_root) + + assert result.files_integrated == 3 + assert (self.project_root / ".claude" / "agents" / "security-apm.md").exists() + assert (self.project_root / ".claude" / "agents" / "planner-apm.md").exists() + assert (self.project_root / ".claude" / "agents" / "default-apm.md").exists() + + def test_integrate_agents_from_apm_agents_dir(self): + """Test finding agents in .apm/agents/ subdirectory.""" + package_dir = self.project_root / "package" + apm_agents = package_dir / ".apm" / "agents" + apm_agents.mkdir(parents=True) + (apm_agents / "reviewer.agent.md").write_text("# Code Reviewer") + + package_info = self._create_package_info(package_dir) + result = self.integrator.integrate_package_agents_claude(package_info, self.project_root) + + assert result.files_integrated == 1 + assert (self.project_root / ".claude" / "agents" / "reviewer-apm.md").exists() + + def test_integrate_no_agents_returns_empty_result(self): + """Test empty result when no agent files found.""" + package_dir = self.project_root / "package" + package_dir.mkdir() + (package_dir / "readme.md").write_text("# Not an agent") + + package_info = self._create_package_info(package_dir) + result = self.integrator.integrate_package_agents_claude(package_info, self.project_root) + + assert result.files_integrated == 0 + assert not (self.project_root / ".claude" / "agents").exists() + + def test_integrate_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("# Updated Content") + + # Pre-create target + agents_dir = self.project_root / ".claude" / "agents" + agents_dir.mkdir(parents=True) + (agents_dir / "security-apm.md").write_text("# Old Content") + + package_info = self._create_package_info(package_dir) + result = self.integrator.integrate_package_agents_claude(package_info, self.project_root) + + assert result.files_integrated == 1 + content = (agents_dir / "security-apm.md").read_text() + assert "Updated Content" in content + + def test_integrate_preserves_frontmatter(self): + """Test that YAML frontmatter is preserved in Claude agents.""" + package_dir = self.project_root / "package" + package_dir.mkdir() + content = """--- +name: security-reviewer +description: Reviews code for security issues +tools: Read, Grep, Glob +model: sonnet +--- + +You are a security reviewer. Analyze code for vulnerabilities.""" + (package_dir / "security.agent.md").write_text(content) + + package_info = self._create_package_info(package_dir) + self.integrator.integrate_package_agents_claude(package_info, self.project_root) + + target_content = (self.project_root / ".claude" / "agents" / "security-apm.md").read_text() + assert "name: security-reviewer" in target_content + assert "description: Reviews code for security issues" in target_content + assert "security reviewer" in target_content + + def test_sync_integration_claude_removes_apm_agents(self): + """Test sync removes APM-managed agents from .claude/agents/.""" + agents_dir = self.project_root / ".claude" / "agents" + agents_dir.mkdir(parents=True) + (agents_dir / "security-apm.md").write_text("# APM managed") + (agents_dir / "planner-apm.md").write_text("# APM managed") + (agents_dir / "custom.md").write_text("# User created") + + result = self.integrator.sync_integration_claude(None, self.project_root) + + assert result['files_removed'] == 2 + assert not (agents_dir / "security-apm.md").exists() + assert not (agents_dir / "planner-apm.md").exists() + assert (agents_dir / "custom.md").exists() # Preserved + + def test_sync_integration_claude_handles_missing_dir(self): + """Test sync handles missing .claude/agents/ gracefully.""" + result = self.integrator.sync_integration_claude(None, self.project_root) + + assert result['files_removed'] == 0 + assert result['errors'] == 0 + + def test_update_gitignore_claude_adds_pattern(self): + """Test .gitignore is updated with Claude agent pattern.""" + gitignore = self.project_root / ".gitignore" + gitignore.write_text("node_modules/\n") + + updated = self.integrator.update_gitignore_for_integrated_agents_claude(self.project_root) + + assert updated + content = gitignore.read_text() + assert ".claude/agents/*-apm.md" in content + + def test_update_gitignore_claude_skips_if_exists(self): + """Test .gitignore is not updated if pattern already present.""" + gitignore = self.project_root / ".gitignore" + gitignore.write_text("node_modules/\n.claude/agents/*-apm.md\n") + + updated = self.integrator.update_gitignore_for_integrated_agents_claude(self.project_root) + + assert not updated + + def test_gitignore_pattern_matches_claude_suffix_files(self): + """Test that gitignore pattern matches -apm.md files.""" + import fnmatch + + pattern = "*-apm.md" + + assert fnmatch.fnmatch("security-apm.md", pattern) + assert fnmatch.fnmatch("backend-engineer-apm.md", pattern) + assert fnmatch.fnmatch("default-apm.md", pattern) + + # Should NOT match non-APM files + assert not fnmatch.fnmatch("security.md", pattern) + assert not fnmatch.fnmatch("custom-agent.md", pattern) From 1392a71aa96c929441719c5fa9a06fb8064ab765 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 22:11:05 +0000 Subject: [PATCH 3/5] docs: update integrations and CLI reference for Claude agent deployment Co-authored-by: danielmeppiel <51440732+danielmeppiel@users.noreply.github.com> --- docs/cli-reference.md | 2 ++ docs/integrations.md | 34 +++++++++++++++++++++++++++------- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 80738090f..daeaa686e 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -222,6 +222,7 @@ When you run `apm install`, APM automatically integrates primitives from install APM also integrates with Claude Code when `.claude/` directory exists: +- **Agents**: `.agent.md` and `.chatmode.md` files → `.claude/agents/*-apm.md` - **Commands**: `.prompt.md` files → `.claude/commands/*-apm.md` **Skill Integration:** @@ -235,6 +236,7 @@ Skills are copied directly to target directories: ``` ✓ microsoft/apm-sample-package ├─ 3 prompts integrated → .github/prompts/ + ├─ 1 agents integrated → .claude/agents/ └─ 3 commands integrated → .claude/commands/ ``` diff --git a/docs/integrations.md b/docs/integrations.md index 65b0bee5e..0f7208311 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -223,8 +223,27 @@ When you run `apm install`, APM integrates package primitives into Claude's nati | Location | Purpose | |----------|---------|| +| `.claude/agents/*-apm.md` | Sub-agents from installed packages (from `.agent.md` files) | | `.claude/commands/*.md` | Slash commands from installed packages (from `.prompt.md` files) | -| `.github/skills/{folder}/` | Skills from packages with `SKILL.md` or `.apm/` primitives | +| `.claude/skills/{folder}/` | Skills from packages with `SKILL.md` or `.apm/` primitives | + +### Automatic Agent Integration + +APM automatically deploys agent files from installed packages into `.claude/agents/`: + +```bash +# Install a package with agents +apm install danielmeppiel/design-guidelines + +# Result: +# .claude/agents/security-apm.md → Sub-agent available for Claude Code +``` + +**How it works:** +1. `apm install` detects `.agent.md` and `.chatmode.md` files in the package +2. Copies each to `.claude/agents/` as `.md` files with `-apm` suffix +3. Updates `.gitignore` to exclude generated agents +4. `apm uninstall` automatically removes the package's agents ### Automatic Command Integration @@ -303,7 +322,7 @@ Review the current design for accessibility and UI standards. ### Example Workflow ```bash -# 1. Install packages (integrates commands and skills automatically) +# 1. Install packages (integrates agents, commands, and skills automatically) apm install microsoft/apm-sample-package apm install github/awesome-copilot/skills/review-and-refactor @@ -315,19 +334,20 @@ apm compile --target claude # /gdpr-assessment → Runs GDPR compliance check # 4. CLAUDE.md provides project instructions automatically -# 5. Skills in .github/skills/ are available for agents to reference +# 5. Agents in .claude/agents/ are available as sub-agents +# 6. Skills in .claude/skills/ are available for agents to reference ``` ### Claude Desktop Integration -Skills installed to `.github/skills/` are automatically available for AI agents. Each skill folder contains a `SKILL.md` that defines the skill's capabilities and any supporting files. +Skills installed to `.claude/skills/` are automatically available for Claude Code. Each skill folder contains a `SKILL.md` that defines the skill's capabilities and any supporting files. ### Cleanup and Sync -APM maintains synchronization between packages and Claude commands: +APM maintains synchronization between packages and Claude primitives: -- **Install**: Adds commands for new packages -- **Uninstall**: Removes only that package's commands +- **Install**: Adds agents, commands, and skills for new packages +- **Uninstall**: Removes only that package's agents and commands - **Update**: Refreshes commands when package version changes - **Virtual Packages**: Individual files and skills (e.g., `github/awesome-copilot/skills/review-and-refactor`) are tracked and removed correctly From 50e023925218c9638f462133bb3be65946ac5e94 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Tue, 24 Feb 2026 09:05:47 +0100 Subject: [PATCH 4/5] fix: address PR review comments - Fix docs table: commands pattern is *-apm.md not *.md - Remove trailing whitespace in TestClaudeAgentIntegration - Guard integrate_package_agents_claude() on uninstall behind Path('.claude').exists() to prevent creating .claude/ in non-Claude projects --- docs/integrations.md | 2 +- src/apm_cli/cli.py | 3 ++- tests/unit/integration/test_agent_integrator.py | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/integrations.md b/docs/integrations.md index 0f7208311..a152fd9fc 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -224,7 +224,7 @@ When you run `apm install`, APM integrates package primitives into Claude's nati | Location | Purpose | |----------|---------|| | `.claude/agents/*-apm.md` | Sub-agents from installed packages (from `.agent.md` files) | -| `.claude/commands/*.md` | Slash commands from installed packages (from `.prompt.md` files) | +| `.claude/commands/*-apm.md` | Slash commands from installed packages (from `.prompt.md` files) | | `.claude/skills/{folder}/` | Skills from packages with `SKILL.md` or `.apm/` primitives | ### Automatic Agent Integration diff --git a/src/apm_cli/cli.py b/src/apm_cli/cli.py index 8aae937db..fc76fec36 100644 --- a/src/apm_cli/cli.py +++ b/src/apm_cli/cli.py @@ -1377,7 +1377,8 @@ def _find_transitive_orphans(lockfile, removed_urls): 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) - agent_integrator.integrate_package_agents_claude(pkg_info, project_root) + if Path(".claude").exists(): + agent_integrator.integrate_package_agents_claude(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) diff --git a/tests/unit/integration/test_agent_integrator.py b/tests/unit/integration/test_agent_integrator.py index 6d35932bf..6113b64fd 100644 --- a/tests/unit/integration/test_agent_integrator.py +++ b/tests/unit/integration/test_agent_integrator.py @@ -588,7 +588,7 @@ def test_gitignore_pattern_matches_suffix_files(self): class TestClaudeAgentIntegration: """Tests for Claude agent integration (.claude/agents/).""" - + def setup_method(self): """Set up test fixtures.""" self.temp_dir = tempfile.mkdtemp() From 04af42bafa377320b10f87cd46b2699a03f62eb4 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Thu, 26 Feb 2026 17:13:44 +0100 Subject: [PATCH 5/5] fix: rename .chatmode.md to .agent.md when deploying agents (chatmode is legacy) --- docs/cli-reference.md | 4 +- src/apm_cli/integration/agent_integrator.py | 44 ++++++++----------- .../unit/integration/test_agent_integrator.py | 21 +++++---- tests/unit/test_orphan_detection.py | 7 +-- 4 files changed, 34 insertions(+), 42 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index daeaa686e..5c6a1cdfb 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -212,7 +212,7 @@ When you run `apm install`, APM automatically integrates primitives from install - **Prompts**: `.prompt.md` files → `.github/prompts/*-apm.prompt.md` - **Agents**: `.agent.md` files → `.github/agents/*-apm.agent.md` -- **Chatmodes**: `.chatmode.md` files → `.github/agents/*-apm.chatmode.md` +- **Chatmodes**: `.chatmode.md` files → `.github/agents/*-apm.agent.md` (renamed to modern format) - **Control**: Disable with `apm config set auto-integrate false` - **Smart updates**: Only updates when package version/commit changes - **Naming**: Integrated files use `-apm` suffix (e.g., `accessibility-audit-apm.prompt.md`) @@ -275,7 +275,7 @@ apm uninstall microsoft/apm-sample-package --dry-run | Transitive deps | `apm_modules/` (orphaned transitive dependencies) | | Integrated prompts | `.github/prompts/*-apm.prompt.md` | | Integrated agents | `.github/agents/*-apm.agent.md` | -| Integrated chatmodes | `.github/agents/*-apm.chatmode.md` | +| Integrated chatmodes | `.github/agents/*-apm.agent.md` | | Claude commands | `.claude/commands/*-apm.md` | | Skill folders | `.github/skills/{folder-name}/` | | Lockfile entries | `apm.lock` (removed packages + orphaned transitives) | diff --git a/src/apm_cli/integration/agent_integrator.py b/src/apm_cli/integration/agent_integrator.py index 0211259cf..78f3286bb 100644 --- a/src/apm_cli/integration/agent_integrator.py +++ b/src/apm_cli/integration/agent_integrator.py @@ -98,27 +98,25 @@ def get_target_filename(self, source_file: Path, package_name: str) -> str: package_name: Name of the package (not used in simple naming) Returns: - str: Target filename with -apm suffix (e.g., security-apm.agent.md or security-apm.chatmode.md) + str: Target filename with -apm suffix (e.g., security-apm.agent.md) """ # Intent-first naming: insert -apm suffix before extension - # Preserve original extension (.agent.md or .chatmode.md) + # Always deploy as .agent.md (.chatmode.md is legacy) # Examples: # security.agent.md -> security-apm.agent.md - # default.chatmode.md -> default-apm.chatmode.md + # default.chatmode.md -> default-apm.agent.md - # Determine extension + # Determine extension — always deploy as .agent.md + # (.chatmode.md is legacy; VS Code now uses .agent.md) if source_file.name.endswith('.agent.md'): stem = source_file.name[:-9] # Remove .agent.md - extension = '.agent.md' elif source_file.name.endswith('.chatmode.md'): stem = source_file.name[:-12] # Remove .chatmode.md - extension = '.chatmode.md' else: # Fallback for unexpected naming stem = source_file.stem - extension = ''.join(source_file.suffixes) - return f"{stem}-apm{extension}" + return f"{stem}-apm.agent.md" def copy_agent(self, source: Path, target: Path) -> int: """Copy agent file verbatim, resolving context links. @@ -323,19 +321,16 @@ def sync_integration(self, apm_package, project_root: Path) -> Dict[str, int]: """ stats = {'files_removed': 0, 'errors': 0} - for agents_dir in [ - project_root / ".github" / "agents", - project_root / ".claude" / "agents", - ]: - if not agents_dir.exists(): - continue - for pattern in ["*-apm.agent.md", "*-apm.chatmode.md"]: - for agent_file in agents_dir.glob(pattern): - try: - agent_file.unlink() - stats['files_removed'] += 1 - except Exception: - stats['errors'] += 1 + agents_dir = project_root / ".github" / "agents" + if not agents_dir.exists(): + return stats + + for agent_file in agents_dir.glob("*-apm.agent.md"): + try: + agent_file.unlink() + stats['files_removed'] += 1 + except Exception: + stats['errors'] += 1 return stats @@ -375,12 +370,9 @@ def update_gitignore_for_integrated_agents(self, project_root: Path) -> bool: """ gitignore_path = project_root / ".gitignore" - # Define patterns for both new and legacy formats, plus .claude/ variants + # Pattern for integrated agent files (chatmode.md renamed to agent.md on deploy) patterns = [ - ".github/agents/*-apm.agent.md", - ".github/agents/*-apm.chatmode.md", - ".claude/agents/*-apm.agent.md", - ".claude/agents/*-apm.chatmode.md", + ".github/agents/*-apm.agent.md" ] # Read current content diff --git a/tests/unit/integration/test_agent_integrator.py b/tests/unit/integration/test_agent_integrator.py index 6113b64fd..978787d9b 100644 --- a/tests/unit/integration/test_agent_integrator.py +++ b/tests/unit/integration/test_agent_integrator.py @@ -120,13 +120,13 @@ def test_get_target_filename_agent_format(self): assert target == "security-apm.agent.md" def test_get_target_filename_chatmode_format(self): - """Test target filename generation with -apm suffix for .chatmode.md.""" + """Test target filename generation renames .chatmode.md to .agent.md.""" source = Path("/package/default.chatmode.md") package_name = "microsoft/apm-sample-package" target = self.integrator.get_target_filename(source, package_name) - # Preserve original extension - assert target == "default-apm.chatmode.md" + # chatmode is legacy — deploy as .agent.md + assert target == "default-apm.agent.md" @@ -212,12 +212,11 @@ def test_update_gitignore_adds_patterns(self): assert updated == True content = gitignore.read_text() assert ".github/agents/*-apm.agent.md" in content - assert ".github/agents/*-apm.chatmode.md" in content def test_update_gitignore_skips_if_exists(self): """Test that gitignore update is skipped if patterns exist.""" gitignore = self.project_root / ".gitignore" - gitignore.write_text(".github/agents/*-apm.agent.md\n.github/agents/*-apm.chatmode.md\n.claude/agents/*-apm.agent.md\n.claude/agents/*-apm.chatmode.md\n") + gitignore.write_text(".github/agents/*-apm.agent.md\n") updated = self.integrator.update_gitignore_for_integrated_agents(self.project_root) @@ -390,19 +389,19 @@ def test_sync_integration_removes_all_apm_agents(self): assert not (github_agents / "security-apm.agent.md").exists() assert not (github_agents / "compliance-apm.agent.md").exists() - def test_sync_integration_removes_apm_chatmodes(self): - """Test that sync removes APM-managed chatmode files.""" + def test_sync_integration_removes_renamed_chatmode_agents(self): + """Test that sync removes agents that were originally chatmode files (now deployed as .agent.md).""" github_agents = self.project_root / ".github" / "agents" github_agents.mkdir(parents=True) - (github_agents / "default-apm.chatmode.md").write_text("# Default Chatmode") + (github_agents / "default-apm.agent.md").write_text("# Default Agent (was chatmode)") apm_package = Mock() result = self.integrator.sync_integration(apm_package, self.project_root) assert result['files_removed'] == 1 - assert not (github_agents / "default-apm.chatmode.md").exists() + assert not (github_agents / "default-apm.agent.md").exists() def test_sync_integration_preserves_non_apm_files(self): """Test that sync does not remove non-APM files.""" @@ -541,10 +540,10 @@ def test_suffix_with_simple_agent_filename(self): assert result == "security-apm.agent.md" def test_suffix_with_simple_chatmode_filename(self): - """Test suffix pattern with simple chatmode filename.""" + """Test suffix pattern renames chatmode to agent format.""" source = Path("default.chatmode.md") result = self.integrator.get_target_filename(source, "pkg") - assert result == "default-apm.chatmode.md" + assert result == "default-apm.agent.md" def test_suffix_with_hyphenated_filename(self): """Test suffix pattern with hyphenated filename.""" diff --git a/tests/unit/test_orphan_detection.py b/tests/unit/test_orphan_detection.py index 93f7038dc..c13052d85 100644 --- a/tests/unit/test_orphan_detection.py +++ b/tests/unit/test_orphan_detection.py @@ -129,13 +129,14 @@ def test_sync_preserves_non_apm_files(self): assert not (agents_dir / "test-apm.agent.md").exists() def test_sync_removes_chatmode_files(self): - """Legacy .chatmode.md files are also removed.""" + """Legacy .chatmode.md files deployed as .agent.md are removed correctly.""" with tempfile.TemporaryDirectory() as tmpdir: project_root = Path(tmpdir) agents_dir = project_root / ".github" / "agents" agents_dir.mkdir(parents=True) - (agents_dir / "test-apm.chatmode.md").write_text("# Chatmode") + # Chatmodes are now deployed as .agent.md, so sync removes them via that pattern + (agents_dir / "test-apm.agent.md").write_text("# Agent (was chatmode)") apm_package = create_mock_apm_package([]) @@ -143,7 +144,7 @@ def test_sync_removes_chatmode_files(self): result = integrator.sync_integration(apm_package, project_root) assert result['files_removed'] == 1 - assert not (agents_dir / "test-apm.chatmode.md").exists() + assert not (agents_dir / "test-apm.agent.md").exists() class TestPromptIntegratorOrphanDetection: