From 2eb248425b1469347a58b01ddd8c59729d99c028 Mon Sep 17 00:00:00 2001 From: Stephen Benjamin Date: Wed, 29 Apr 2026 08:34:30 -0400 Subject: [PATCH 1/3] fix(install): map APM prompt 'input' to Claude 'arguments' front-matter Slash commands installed from APM packages did not wire `input:` parameters through to Claude Code's native `arguments:` front-matter, so users got no argument hints when invoking the command. This is a blocker for teams rolling out APM across multiple repos -- every `/command` that expects arguments silently drops the hints. Changes: - Map `input:` (list, object-list, bare dict, single string) to `arguments:` in the compiled Claude command. - Convert `${input:name}` body references to `$name` placeholders. - Auto-generate `argument-hint` from input names when not explicitly set. - Move command-generation helpers out of `ClaudeFormatter` (compilation layer) into `CommandIntegrator` (integration layer) where they belong. - Remove orphaned `CommandGenerationResult` dataclass (no production callers remained). - Add defense-in-depth `SecurityGate.scan_text()` to the `integrate_command()` write path. - Document input formats and target-specific mapping in three docs pages and the apm-usage skill resource. Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 1 + docs/src/content/docs/guides/prompts.md | 43 +++ .../docs/integrations/ide-tool-integration.md | 32 +- .../skills/apm-usage/package-authoring.md | 16 +- src/apm_cli/compilation/claude_formatter.py | 267 +------------ src/apm_cli/integration/command_integrator.py | 145 +++++-- .../unit/compilation/test_claude_formatter.py | 362 +----------------- .../integration/test_command_integrator.py | 323 +++++++++++++++- tests/unit/test_install_scanning.py | 16 - 9 files changed, 541 insertions(+), 664 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b047cfc96..ecee11476 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- `apm install` now maps APM prompt `input:` frontmatter to Claude `arguments:` frontmatter, converts `${input:name}` references to `$name` placeholders, and auto-generates `argument-hint` -- slash commands installed from APM packages now surface argument hints in Claude Code. (#1039) - `apm install` and `apm compile` no longer exit 0 with success messages when `target:` in `apm.yml` is a CSV string -- the value now parses identically to the same input on `--target`, and zero-target resolution surfaces a warning instead of a silent no-op. (#820) - Remove redundant `seen` set from `_scan_patterns()` discovery walk (#918) - `apm marketplace build` now respects `GITHUB_HOST` for GitHub Enterprise repos -- ref resolution, token lookup, and metadata fetch all use the configured host instead of hardcoded `github.com`. `git ls-remote` is authenticated so private repos work without separate credential setup. (#1008) diff --git a/docs/src/content/docs/guides/prompts.md b/docs/src/content/docs/guides/prompts.md index 4fdd89be8..d33bb7e13 100644 --- a/docs/src/content/docs/guides/prompts.md +++ b/docs/src/content/docs/guides/prompts.md @@ -99,6 +99,49 @@ Reference script inputs using the `${input:name}` syntax: - Start time: ${input:start_time} ``` +### Input formats + +The `input:` frontmatter key accepts several formats: + +```yaml +# Simple list (most common) +input: + - service_name + - environment + +# Object list with descriptions +input: + - service_name: "Name of the service to analyze" + - environment: "Target environment (prod, staging)" + +# Bare dictionary +input: + service_name: "Name of the service" + environment: "Target environment" + +# Single string (one parameter) +input: service_name +``` + +### Target-specific mapping + +When APM installs a prompt as a Claude Code slash command, it maps `input:` to Claude's native `arguments:` frontmatter. The `${input:name}` references in the prompt body are converted to `$name` placeholders, and an `argument-hint` is auto-generated if one is not already set. + +```yaml +# APM prompt frontmatter +input: + - feature_name + - priority + +# Becomes Claude command frontmatter +arguments: + - feature_name + - priority +argument-hint: +``` + +This mapping is automatic during `apm install` -- no extra configuration is needed. If you set an explicit `argument-hint:` in the prompt frontmatter, APM preserves it instead of generating one. + ## MCP servers in prompts Prompts can declare MCP server dependencies in their frontmatter under the `mcp:` key (see the deployment-health-check example below). To add an MCP server to your project, see the [MCP Servers guide](../mcp-servers/). diff --git a/docs/src/content/docs/integrations/ide-tool-integration.md b/docs/src/content/docs/integrations/ide-tool-integration.md index 26f4bdca6..b19cfb3f4 100644 --- a/docs/src/content/docs/integrations/ide-tool-integration.md +++ b/docs/src/content/docs/integrations/ide-tool-integration.md @@ -272,7 +272,37 @@ apm install microsoft/apm-sample-package **How it works:** 1. `apm install` detects `.prompt.md` files in the package 2. Converts each to Claude command format in `.claude/commands/` -3. `apm uninstall` automatically removes the package's commands +3. Maps APM `input:` frontmatter to Claude `arguments:` frontmatter +4. Converts `${input:name}` references to `$name` placeholders +5. Auto-generates `argument-hint` from input names (unless one is already set) +6. `apm uninstall` automatically removes the package's commands + +**Input-to-arguments mapping example:** + +```yaml +# APM prompt (.prompt.md) +--- +description: Review a feature +input: + - feature_name + - priority +--- +Review ${input:feature_name} with priority ${input:priority}. +``` + +Becomes: + +```yaml +# Claude command (.claude/commands/review.md) +--- +description: Review a feature +arguments: + - feature_name + - priority +argument-hint: +--- +Review $feature_name with priority $priority. +``` ### Automatic Skills Integration diff --git a/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md b/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md index d4df08241..6c2f34d2a 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md +++ b/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md @@ -122,19 +122,23 @@ applyTo: "**/*" ### 4. Prompt / Agent Workflow (`*.prompt.md`) -Executable workflows with parameters. +Executable workflows with parameters. Use the `input:` key to declare +parameters, and `${input:name}` to reference them in the prompt body. ```yaml --- description: "Code review workflow" -model: "gpt-4" -parameters: - - name: pr_url - description: "GitHub PR URL" - required: true +input: + - pr_url + - focus_areas --- +Review ${input:pr_url} focusing on ${input:focus_areas}. ``` +When installed as a Claude Code slash command, APM maps `input:` to +Claude's `arguments:` frontmatter and converts `${input:name}` to `$name` +placeholders. An `argument-hint` is auto-generated unless one is already set. + ### 5. Agent (`*.agent.md`) Agent persona and behavior definition. diff --git a/src/apm_cli/compilation/claude_formatter.py b/src/apm_cli/compilation/claude_formatter.py index fe78fddac..29a5fe9fa 100644 --- a/src/apm_cli/compilation/claude_formatter.py +++ b/src/apm_cli/compilation/claude_formatter.py @@ -3,18 +3,13 @@ This module generates CLAUDE.md files following the Claude Memory documentation format, using the same distributed strategy as AGENTS.md compilation. It produces a parallel output format specifically optimized for Claude's project memory system. - -Also handles generation of .claude/commands/ from APM prompts (.prompt.md files). """ import builtins -import re from collections import defaultdict from dataclasses import dataclass, field from pathlib import Path -from typing import Dict, List, Optional, Set, Tuple - -import frontmatter +from typing import Dict, List, Optional, Set from ..primitives.models import Instruction, PrimitiveCollection, Chatmode from ..version import get_version @@ -346,236 +341,6 @@ def _compile_stats( "total_dependencies": total_deps, "primitives_found": primitives.count(), } - - def generate_commands( - self, - prompt_files: List[Path], - dry_run: bool = False - ) -> "CommandGenerationResult": - """Generate .claude/commands/ from APM prompt files. - - Transforms .prompt.md files into Claude Code custom slash commands. - Each prompt becomes a command file in .claude/commands/{name}.md. - - Args: - prompt_files (List[Path]): List of .prompt.md file paths to transform. - dry_run (bool): If True, preview without writing files. - - Returns: - CommandGenerationResult: Result of the command generation. - """ - commands_dir = self.base_dir / ".claude" / "commands" - generated_commands: Dict[Path, str] = {} - warnings: List[str] = [] - errors: List[str] = [] - - for prompt_path in prompt_files: - try: - # Parse the prompt file - command_name, content, parse_warnings = self._transform_prompt_to_command(prompt_path) - warnings.extend(parse_warnings) - - if content: - command_path = commands_dir / f"{command_name}.md" - generated_commands[command_path] = content - - except Exception as e: - errors.append(f"Failed to transform {prompt_path.name}: {str(e)}") - - # Write files if not dry run - files_written = 0 - critical_security_found = False - if not dry_run and generated_commands: - try: - from ..security.gate import WARN_POLICY, SecurityGate - commands_dir.mkdir(parents=True, exist_ok=True) - - for command_path, content in generated_commands.items(): - # Defense-in-depth: scan compiled command before writing - verdict = SecurityGate.scan_text( - content, str(command_path), policy=WARN_POLICY - ) - actionable = verdict.critical_count + verdict.warning_count - if actionable: - if verdict.has_critical: - critical_security_found = True - warnings.append( - f"{command_path.name}: {actionable} hidden character(s) " - f"— run 'apm audit --file {command_path}' to inspect" - ) - command_path.write_text(content, encoding='utf-8') - files_written += 1 - - except Exception as e: - errors.append(f"Failed to write commands: {str(e)}") - - return CommandGenerationResult( - success=len(errors) == 0, - commands_generated=generated_commands, - commands_dir=commands_dir, - files_written=files_written, - warnings=warnings, - errors=errors, - has_critical_security=critical_security_found, - ) - - def _transform_prompt_to_command( - self, - prompt_path: Path - ) -> Tuple[str, str, List[str]]: - """Transform a single .prompt.md file into Claude command format. - - Args: - prompt_path (Path): Path to the .prompt.md file. - - Returns: - Tuple[str, str, List[str]]: (command_name, content, warnings) - """ - warnings: List[str] = [] - - # Parse the prompt file with frontmatter - post = frontmatter.load(prompt_path) - - # Extract command name from filename - # e.g., "code-review.prompt.md" -> "code-review" - # e.g., "security/audit.prompt.md" -> "audit" (flatten nested paths) - filename = prompt_path.name - if filename.endswith('.prompt.md'): - command_name = filename[:-len('.prompt.md')] - else: - command_name = prompt_path.stem - - # Build Claude command frontmatter - claude_frontmatter = {} - - # Map APM frontmatter to Claude frontmatter - # Claude supports: description, allowed-tools, model, argument-hint - if 'description' in post.metadata: - claude_frontmatter['description'] = post.metadata['description'] - - if 'allowed-tools' in post.metadata: - claude_frontmatter['allowed-tools'] = post.metadata['allowed-tools'] - elif 'allowedTools' in post.metadata: - # Support camelCase variant - claude_frontmatter['allowed-tools'] = post.metadata['allowedTools'] - - if 'model' in post.metadata: - claude_frontmatter['model'] = post.metadata['model'] - - if 'argument-hint' in post.metadata: - claude_frontmatter['argument-hint'] = post.metadata['argument-hint'] - elif 'argumentHint' in post.metadata: - claude_frontmatter['argument-hint'] = post.metadata['argumentHint'] - - # Get the prompt content - content = post.content.strip() - - # Check if content already has $ARGUMENTS or positional args - has_arguments_placeholder = bool( - re.search(r'\$ARGUMENTS|\$\d+', content) - ) - - # Append $ARGUMENTS placeholder if not present - if not has_arguments_placeholder: - content = content + "\n\n$ARGUMENTS" - warnings.append( - f"Added $ARGUMENTS placeholder to {prompt_path.name}" - ) - - # Build the final command file content - command_content = self._build_command_content(claude_frontmatter, content) - - return command_name, command_content, warnings - - def _build_command_content( - self, - frontmatter_dict: Dict[str, str], - content: str - ) -> str: - """Build the final command file content with frontmatter. - - Args: - frontmatter_dict (Dict[str, str]): Frontmatter key-value pairs. - content (str): The command content. - - Returns: - str: Complete command file content. - """ - sections = [] - - # Add frontmatter if we have any metadata - if frontmatter_dict: - sections.append("---") - for key, value in frontmatter_dict.items(): - # Handle multi-word values that need quoting - if isinstance(value, str) and (':' in value or '\n' in value): - sections.append(f'{key}: "{value}"') - else: - sections.append(f"{key}: {value}") - sections.append("---") - sections.append("") - - # Add the content - sections.append(content) - sections.append("") - - return "\n".join(sections) - - def discover_prompt_files(self) -> List[Path]: - """Discover all .prompt.md files in the project. - - Searches in standard APM locations: - - .apm/prompts/ - - .github/prompts/ - - apm_modules/*/prompts/ (installed dependencies) - - Returns: - List[Path]: List of discovered prompt file paths. - """ - prompt_files: List[Path] = [] - - # Search in .apm/prompts/ - apm_prompts = self.base_dir / ".apm" / "prompts" - if apm_prompts.exists(): - prompt_files.extend(apm_prompts.rglob("*.prompt.md")) - - # Search in .github/prompts/ - github_prompts = self.base_dir / ".github" / "prompts" - if github_prompts.exists(): - prompt_files.extend(github_prompts.rglob("*.prompt.md")) - - # Search in root directory - prompt_files.extend(self.base_dir.glob("*.prompt.md")) - - # Search in apm_modules (installed dependencies) - apm_modules = self.base_dir / "apm_modules" - if apm_modules.exists(): - for package_dir in apm_modules.rglob("prompts"): - if package_dir.is_dir(): - prompt_files.extend(package_dir.glob("*.prompt.md")) - - # Remove duplicates while preserving order - seen = set() - unique_files = [] - for f in prompt_files: - abs_path = f.resolve() - if abs_path not in seen: - seen.add(abs_path) - unique_files.append(f) - - return unique_files - - -@dataclass -class CommandGenerationResult: - """Result of .claude/commands/ generation.""" - success: bool - commands_generated: Dict[Path, str] # command_path -> content - commands_dir: Path - files_written: int - warnings: List[str] = field(default_factory=list) - errors: List[str] = field(default_factory=list) - has_critical_security: bool = False def format_claude_md( @@ -599,33 +364,3 @@ def format_claude_md( return formatter.format_distributed(primitives, placement_map, config) -def generate_claude_commands( - base_dir: str = ".", - prompt_files: Optional[List[Path]] = None, - dry_run: bool = False -) -> CommandGenerationResult: - """Convenience function to generate .claude/commands/ from prompts. - - Transforms APM .prompt.md files into Claude Code custom slash commands. - - Args: - base_dir (str): Base directory for compilation. - prompt_files (Optional[List[Path]]): Specific prompt files to transform. - If None, discovers prompts automatically. - dry_run (bool): If True, preview without writing files. - - Returns: - CommandGenerationResult: Result of the command generation. - - Example: - >>> result = generate_claude_commands(".", dry_run=True) - >>> print(f"Would generate {len(result.commands_generated)} commands") - >>> for path, content in result.commands_generated.items(): - ... print(f" /{path.stem}: {len(content)} bytes") - """ - formatter = ClaudeFormatter(base_dir) - - if prompt_files is None: - prompt_files = formatter.discover_prompt_files() - - return formatter.generate_commands(prompt_files, dry_run=dry_run) diff --git a/src/apm_cli/integration/command_integrator.py b/src/apm_cli/integration/command_integrator.py index f7e45c1cf..72b5e3d8c 100644 --- a/src/apm_cli/integration/command_integrator.py +++ b/src/apm_cli/integration/command_integrator.py @@ -6,9 +6,10 @@ from __future__ import annotations +import logging import re from pathlib import Path -from typing import TYPE_CHECKING, Dict, List +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple import frontmatter from apm_cli.integration.base_integrator import BaseIntegrator, IntegrationResult @@ -17,6 +18,46 @@ if TYPE_CHECKING: from apm_cli.integration.targets import TargetProfile +logger = logging.getLogger(__name__) + + +def _extract_input_names(input_spec: Any) -> List[str]: + """Extract argument names from an APM 'input' front-matter value. + + Handles both formats: + - Simple list: input: [name, category] + - Object list: input: + - feature_name: "desc" + - feature_description: "desc" + + Args: + input_spec: The raw value of the 'input' front-matter key. + + Returns: + List[str]: Ordered list of argument names, or empty list. + """ + if input_spec is None: + return [] + + if isinstance(input_spec, list): + names: List[str] = [] + for item in input_spec: + if isinstance(item, str): + if item.strip(): + names.append(item) + elif isinstance(item, dict): + names.extend(k for k in item.keys() if k.strip()) + return names + + if isinstance(input_spec, str): + return [input_spec] if input_spec.strip() else [] + + if isinstance(input_spec, dict): + return [k for k in input_spec.keys() if k.strip()] + + return [] + + # Re-export for backward compat (tests import CommandIntegrationResult) CommandIntegrationResult = IntegrationResult @@ -34,77 +75,134 @@ def find_prompt_files(self, package_path: Path) -> List[Path]: package_path, "*.prompt.md", subdirs=[".apm/prompts"] ) - def _transform_prompt_to_command(self, source: Path) -> tuple: + def _transform_prompt_to_command( + self, source: Path, + ) -> Tuple[str, frontmatter.Post, List[str]]: """Transform a .prompt.md file into Claude command format. - + Args: source: Path to the .prompt.md file - + Returns: - Tuple[str, frontmatter.Post, List[str]]: (command_name, post, warnings) + Tuple of (command_name, post, warnings). """ warnings: List[str] = [] - + post = frontmatter.load(source) - + # Extract command name from filename filename = source.name if filename.endswith('.prompt.md'): command_name = filename[:-len('.prompt.md')] else: command_name = source.stem - + # Build Claude command frontmatter (preserve existing, add Claude-specific) claude_metadata = {} - + # Map APM frontmatter to Claude frontmatter if 'description' in post.metadata: claude_metadata['description'] = post.metadata['description'] - + if 'allowed-tools' in post.metadata: claude_metadata['allowed-tools'] = post.metadata['allowed-tools'] elif 'allowedTools' in post.metadata: claude_metadata['allowed-tools'] = post.metadata['allowedTools'] - + if 'model' in post.metadata: claude_metadata['model'] = post.metadata['model'] - + if 'argument-hint' in post.metadata: claude_metadata['argument-hint'] = post.metadata['argument-hint'] elif 'argumentHint' in post.metadata: claude_metadata['argument-hint'] = post.metadata['argumentHint'] - + + # Map APM 'input' to Claude 'arguments' and 'argument-hint' + input_names = _extract_input_names(post.metadata.get('input')) + if input_names: + claude_metadata['arguments'] = input_names + if 'argument-hint' not in claude_metadata: + claude_metadata['argument-hint'] = " ".join( + f"<{name}>" for name in input_names + ) + + # Convert APM input references to Claude $name placeholders + content = post.content + if input_names: + content = re.sub( + r'\$\{\{?\s*input\s*:\s*([\w-]+)\s*\}?\}', + r'$\1', + content, + ) + # Create new post with Claude metadata - new_post = frontmatter.Post(post.content) + new_post = frontmatter.Post(content) new_post.metadata = claude_metadata - + return (command_name, new_post, warnings) - def integrate_command(self, source: Path, target: Path, package_info, original_path: Path) -> int: + def integrate_command( + self, + source: Path, + target: Path, + package_info: Any, + original_path: Path, + *, + diagnostics: Any = None, + ) -> int: """Integrate a prompt file as a Claude command (verbatim copy with format conversion). - + Args: source: Source .prompt.md file path target: Target command file path in .claude/commands/ package_info: PackageInfo object with package metadata original_path: Original path to the prompt file - + diagnostics: Optional DiagnosticCollector for surfacing warnings. + Returns: int: Number of links resolved """ # Transform to command format command_name, post, warnings = self._transform_prompt_to_command(source) - + # Resolve context links in content post.content, links_resolved = self.resolve_links(post.content, source, target) - + + # Defense-in-depth: scan compiled command before writing + compiled = frontmatter.dumps(post) + try: + from apm_cli.security.gate import WARN_POLICY, SecurityGate + verdict = SecurityGate.scan_text(compiled, str(target), policy=WARN_POLICY) + if verdict.has_critical: + warnings.append( + f"{target.name}: critical hidden characters " + f"-- run 'apm audit --file {target}' to inspect" + ) + except (ImportError, OSError, ValueError): + pass + + # Surface any collected warnings + pkg_name = getattr( + getattr(package_info, "package", None), "name", "", + ) + for warning in warnings: + if diagnostics and hasattr(diagnostics, "security"): + diagnostics.security( + message=warning, + package=pkg_name, + detail=warning, + severity="warning", + ) + else: + logger.warning(warning) + # Ensure target directory exists target.parent.mkdir(parents=True, exist_ok=True) - + # Write the command file with open(target, 'w', encoding='utf-8') as f: - f.write(frontmatter.dumps(post)) - + f.write(compiled) + return links_resolved # ------------------------------------------------------------------ @@ -171,6 +269,7 @@ def integrate_commands_for_target( else: links_resolved = self.integrate_command( prompt_file, target_path, package_info, prompt_file, + diagnostics=diagnostics, ) files_integrated += 1 total_links_resolved += links_resolved diff --git a/tests/unit/compilation/test_claude_formatter.py b/tests/unit/compilation/test_claude_formatter.py index 2884cf6f9..70bb522a9 100644 --- a/tests/unit/compilation/test_claude_formatter.py +++ b/tests/unit/compilation/test_claude_formatter.py @@ -1,4 +1,4 @@ -"""Unit tests for ClaudeFormatter - CLAUDE.md generation and commands.""" +"""Unit tests for ClaudeFormatter - CLAUDE.md generation.""" import tempfile import shutil @@ -10,9 +10,7 @@ ClaudeFormatter, ClaudePlacement, ClaudeCompilationResult, - CommandGenerationResult, format_claude_md, - generate_claude_commands, CLAUDE_HEADER, ) from apm_cli.compilation.constants import BUILD_ID_PLACEHOLDER @@ -395,262 +393,6 @@ def test_multiple_chatmodes_excluded(self, temp_project): assert "Test instruction content" in content -class TestGenerateCommands: - """Tests for generate_commands() method - .claude/commands/ generation.""" - - @pytest.fixture - def temp_project(self): - """Create a temporary project directory.""" - temp_dir = tempfile.mkdtemp() - yield Path(temp_dir).resolve() - shutil.rmtree(temp_dir, ignore_errors=True) - - def test_generate_commands_creates_directory(self, temp_project): - """Test that .claude/commands/ directory is created.""" - formatter = ClaudeFormatter(str(temp_project)) - - # Create a prompt file - prompts_dir = temp_project / ".github" / "prompts" - prompts_dir.mkdir(parents=True) - prompt_file = prompts_dir / "code-review.prompt.md" - prompt_file.write_text("""--- -description: Review code for issues ---- -Review the following code for bugs and security issues. -""") - - result = formatter.generate_commands([prompt_file], dry_run=False) - - assert result.success - assert result.files_written == 1 - assert result.commands_dir.exists() - assert (result.commands_dir / "code-review.md").exists() - - def test_generate_commands_dry_run(self, temp_project): - """Test that dry_run mode doesn't write files.""" - formatter = ClaudeFormatter(str(temp_project)) - - prompts_dir = temp_project / ".github" / "prompts" - prompts_dir.mkdir(parents=True) - prompt_file = prompts_dir / "test.prompt.md" - prompt_file.write_text("""--- -description: Test prompt ---- -Test content. -""") - - result = formatter.generate_commands([prompt_file], dry_run=True) - - assert result.success - assert result.files_written == 0 - assert len(result.commands_generated) == 1 - assert not result.commands_dir.exists() - - def test_generate_commands_preserves_frontmatter(self, temp_project): - """Test that command files preserve frontmatter.""" - formatter = ClaudeFormatter(str(temp_project)) - - prompts_dir = temp_project / ".github" / "prompts" - prompts_dir.mkdir(parents=True) - prompt_file = prompts_dir / "review.prompt.md" - prompt_file.write_text("""--- -description: Review code thoroughly -model: claude-3-opus -allowed-tools: Read, Write -argument-hint: ---- -Review this code: $ARGUMENTS -""") - - result = formatter.generate_commands([prompt_file], dry_run=True) - - # Get content by finding the command in the generated dict - content = list(result.commands_generated.values())[0] - - assert "---" in content - assert "description: Review code thoroughly" in content - assert "model: claude-3-opus" in content - assert "allowed-tools: Read, Write" in content - assert "argument-hint: " in content - - def test_generate_commands_adds_arguments_placeholder(self, temp_project): - """Test that $ARGUMENTS placeholder is added when missing.""" - formatter = ClaudeFormatter(str(temp_project)) - - prompts_dir = temp_project / ".github" / "prompts" - prompts_dir.mkdir(parents=True) - prompt_file = prompts_dir / "simple.prompt.md" - prompt_file.write_text("""--- -description: Simple prompt ---- -Do something simple. -""") - - result = formatter.generate_commands([prompt_file], dry_run=True) - - content = list(result.commands_generated.values())[0] - - assert "$ARGUMENTS" in content - assert any("Added $ARGUMENTS placeholder" in w for w in result.warnings) - - def test_generate_commands_preserves_existing_arguments(self, temp_project): - """Test that existing $ARGUMENTS is not duplicated.""" - formatter = ClaudeFormatter(str(temp_project)) - - prompts_dir = temp_project / ".github" / "prompts" - prompts_dir.mkdir(parents=True) - prompt_file = prompts_dir / "with-args.prompt.md" - prompt_file.write_text("""--- -description: Prompt with arguments ---- -Process this input: $ARGUMENTS -""") - - result = formatter.generate_commands([prompt_file], dry_run=True) - - content = list(result.commands_generated.values())[0] - - # Should have exactly one $ARGUMENTS - assert content.count("$ARGUMENTS") == 1 - # No warning about adding placeholder - assert not any("Added $ARGUMENTS" in w for w in result.warnings) - - def test_generate_commands_preserves_positional_args(self, temp_project): - """Test that positional args ($1, $2, etc.) are preserved without adding $ARGUMENTS.""" - formatter = ClaudeFormatter(str(temp_project)) - - prompts_dir = temp_project / ".github" / "prompts" - prompts_dir.mkdir(parents=True) - prompt_file = prompts_dir / "positional.prompt.md" - prompt_file.write_text("""--- -description: Prompt with positional args ---- -Compare $1 with $2. -""") - - result = formatter.generate_commands([prompt_file], dry_run=True) - - content = list(result.commands_generated.values())[0] - - assert "$1" in content - assert "$2" in content - # Should not add $ARGUMENTS when positional args exist - assert not any("Added $ARGUMENTS" in w for w in result.warnings) - - def test_generate_commands_extracts_name_from_filename(self, temp_project): - """Test that command name is extracted from filename correctly.""" - formatter = ClaudeFormatter(str(temp_project)) - - prompts_dir = temp_project / ".github" / "prompts" - prompts_dir.mkdir(parents=True) - - prompt_file = prompts_dir / "my-custom-command.prompt.md" - prompt_file.write_text("""--- -description: Custom command ---- -Content. -""") - - result = formatter.generate_commands([prompt_file], dry_run=True) - - # Check that the command name is correct by looking at the keys - command_names = [p.name for p in result.commands_generated.keys()] - assert "my-custom-command.md" in command_names - - def test_generate_commands_maps_camelcase_frontmatter(self, temp_project): - """Test that camelCase frontmatter is mapped correctly.""" - formatter = ClaudeFormatter(str(temp_project)) - - prompts_dir = temp_project / ".github" / "prompts" - prompts_dir.mkdir(parents=True) - prompt_file = prompts_dir / "camel.prompt.md" - prompt_file.write_text("""--- -description: Test camelCase -allowedTools: Read, Write, Bash -argumentHint: ---- -Content here. -""") - - result = formatter.generate_commands([prompt_file], dry_run=True) - - content = list(result.commands_generated.values())[0] - - # Should convert to kebab-case - assert "allowed-tools: Read, Write, Bash" in content - assert "argument-hint: " in content - - -class TestDiscoverPromptFiles: - """Tests for discover_prompt_files() method.""" - - @pytest.fixture - def temp_project(self): - """Create a temporary project directory.""" - temp_dir = tempfile.mkdtemp() - yield Path(temp_dir) - shutil.rmtree(temp_dir, ignore_errors=True) - - def test_discovers_prompts_in_github_prompts(self, temp_project): - """Test discovery in .github/prompts/.""" - prompts_dir = temp_project / ".github" / "prompts" - prompts_dir.mkdir(parents=True) - (prompts_dir / "test.prompt.md").write_text("Test content") - - formatter = ClaudeFormatter(str(temp_project)) - files = formatter.discover_prompt_files() - - assert len(files) == 1 - assert "test.prompt.md" in str(files[0]) - - def test_discovers_prompts_in_apm_prompts(self, temp_project): - """Test discovery in .apm/prompts/.""" - prompts_dir = temp_project / ".apm" / "prompts" - prompts_dir.mkdir(parents=True) - (prompts_dir / "apm-test.prompt.md").write_text("APM test") - - formatter = ClaudeFormatter(str(temp_project)) - files = formatter.discover_prompt_files() - - assert len(files) == 1 - assert "apm-test.prompt.md" in str(files[0]) - - def test_discovers_prompts_in_root(self, temp_project): - """Test discovery of prompts in root directory.""" - (temp_project / "root-prompt.prompt.md").write_text("Root prompt") - - formatter = ClaudeFormatter(str(temp_project)) - files = formatter.discover_prompt_files() - - assert len(files) == 1 - assert "root-prompt.prompt.md" in str(files[0]) - - def test_discovers_prompts_in_apm_modules(self, temp_project): - """Test discovery in apm_modules dependencies.""" - prompts_dir = temp_project / "apm_modules" / "owner" / "package" / "prompts" - prompts_dir.mkdir(parents=True) - (prompts_dir / "dep-prompt.prompt.md").write_text("Dependency prompt") - - formatter = ClaudeFormatter(str(temp_project)) - files = formatter.discover_prompt_files() - - assert len(files) == 1 - assert "dep-prompt.prompt.md" in str(files[0]) - - def test_no_duplicates_in_discovery(self, temp_project): - """Test that duplicate files are not returned.""" - prompts_dir = temp_project / ".github" / "prompts" - prompts_dir.mkdir(parents=True) - (prompts_dir / "unique.prompt.md").write_text("Unique") - - formatter = ClaudeFormatter(str(temp_project)) - files = formatter.discover_prompt_files() - - # Even if we call it twice, should have unique files - files2 = formatter.discover_prompt_files() - assert len(files) == len(files2) == 1 - - class TestConvenienceFunctions: """Tests for module-level convenience functions.""" @@ -664,7 +406,7 @@ def temp_project(self): def test_format_claude_md_function(self, temp_project): """Test the format_claude_md convenience function.""" primitives = PrimitiveCollection() - + instruction = Instruction( name="test", file_path=temp_project / "test.instructions.md", @@ -674,48 +416,13 @@ def test_format_claude_md_function(self, temp_project): author="test" ) primitives.add_primitive(instruction) - + placement_map = {temp_project: [instruction]} - + result = format_claude_md(primitives, placement_map, str(temp_project)) - - assert result.success - assert len(result.content_map) == 1 - def test_generate_claude_commands_function(self, temp_project): - """Test the generate_claude_commands convenience function.""" - prompts_dir = temp_project / ".github" / "prompts" - prompts_dir.mkdir(parents=True) - (prompts_dir / "test.prompt.md").write_text("""--- -description: Test ---- -Test content. -""") - - result = generate_claude_commands(str(temp_project), dry_run=True) - assert result.success - assert len(result.commands_generated) == 1 - - def test_generate_claude_commands_with_explicit_files(self, temp_project): - """Test generate_claude_commands with explicit file list.""" - prompts_dir = temp_project / ".github" / "prompts" - prompts_dir.mkdir(parents=True) - prompt_file = prompts_dir / "explicit.prompt.md" - prompt_file.write_text("""--- -description: Explicit ---- -Explicit content. -""") - - result = generate_claude_commands( - str(temp_project), - prompt_files=[prompt_file], - dry_run=True - ) - - assert result.success - assert len(result.commands_generated) == 1 + assert len(result.content_map) == 1 class TestDataclasses: @@ -727,7 +434,7 @@ def test_claude_placement_defaults(self): claude_path=Path("test/CLAUDE.md"), instructions=[] ) - + assert placement.agents == [] assert placement.dependencies == [] assert placement.coverage_patterns == set() @@ -740,22 +447,10 @@ def test_claude_compilation_result_defaults(self): placements=[], content_map={} ) - - assert result.warnings == [] - assert result.errors == [] - assert result.stats == {} - def test_command_generation_result_defaults(self): - """Test CommandGenerationResult default values.""" - result = CommandGenerationResult( - success=True, - commands_generated={}, - commands_dir=Path(".claude/commands"), - files_written=0 - ) - assert result.warnings == [] assert result.errors == [] + assert result.stats == {} class TestErrorHandling: @@ -771,11 +466,9 @@ def temp_project(self): def test_format_distributed_handles_exceptions(self, temp_project): """Test that format_distributed handles exceptions gracefully.""" formatter = ClaudeFormatter(str(temp_project)) - - # Pass invalid data that might cause an error + primitives = PrimitiveCollection() - - # Create instruction with None for required field to potentially cause error + instruction = Instruction( name="test", file_path=temp_project / "test.md", @@ -785,40 +478,7 @@ def test_format_distributed_handles_exceptions(self, temp_project): author="test" ) primitives.add_primitive(instruction) - - result = formatter.format_distributed(primitives, {temp_project: [instruction]}) - - # Should succeed or fail gracefully - assert isinstance(result, ClaudeCompilationResult) - def test_generate_commands_handles_invalid_file(self, temp_project): - """Test that generate_commands handles invalid prompt files.""" - formatter = ClaudeFormatter(str(temp_project)) - - # Try to process a non-existent file - nonexistent = temp_project / "nonexistent.prompt.md" - - result = formatter.generate_commands([nonexistent], dry_run=True) - - # Should report error but not crash - assert len(result.errors) > 0 + result = formatter.format_distributed(primitives, {temp_project: [instruction]}) - def test_generate_commands_handles_malformed_frontmatter(self, temp_project): - """Test handling of malformed frontmatter in prompt files.""" - formatter = ClaudeFormatter(str(temp_project)) - - prompts_dir = temp_project / ".github" / "prompts" - prompts_dir.mkdir(parents=True) - - # Create file with invalid YAML frontmatter - prompt_file = prompts_dir / "malformed.prompt.md" - prompt_file.write_text("""--- -description: [unclosed bracket ---- -Content here. -""") - - result = formatter.generate_commands([prompt_file], dry_run=True) - - # Should handle gracefully (either error or skip) - assert isinstance(result, CommandGenerationResult) + assert isinstance(result, ClaudeCompilationResult) diff --git a/tests/unit/integration/test_command_integrator.py b/tests/unit/integration/test_command_integrator.py index 89f7b4226..232f8a8eb 100644 --- a/tests/unit/integration/test_command_integrator.py +++ b/tests/unit/integration/test_command_integrator.py @@ -16,7 +16,10 @@ import pytest import frontmatter -from apm_cli.integration.command_integrator import CommandIntegrator +from apm_cli.integration.command_integrator import ( + CommandIntegrator, + _extract_input_names, +) class TestCommandIntegratorSyncIntegration: @@ -268,6 +271,45 @@ def test_claude_metadata_mapping(self, temp_project): assert 'apm' not in post.metadata +class TestSecurityWarningsSurfaced: + """Verify SecurityGate warnings reach diagnostics.""" + + @pytest.fixture + def temp_project(self): + temp_dir = tempfile.mkdtemp() + temp_path = Path(temp_dir) + (temp_path / "source").mkdir() + (temp_path / ".claude" / "commands").mkdir(parents=True) + yield temp_path + shutil.rmtree(temp_dir, ignore_errors=True) + + def test_critical_chars_recorded_in_diagnostics(self, temp_project): + """SecurityGate critical finding surfaces via diagnostics.security().""" + from apm_cli.utils.diagnostics import DiagnosticCollector + + source = temp_project / "source" / "evil.prompt.md" + source.write_text( + "---\ndescription: Evil\n---\nHidden tag\U000E0041char.\n", + encoding="utf-8", + ) + target = temp_project / ".claude" / "commands" / "evil.md" + + mock_info = MagicMock() + mock_info.package = MagicMock() + mock_info.package.name = "evil-pkg" + mock_info.resolved_reference = None + + diag = DiagnosticCollector() + integrator = CommandIntegrator() + integrator.integrate_command( + source, target, mock_info, source, diagnostics=diag, + ) + + assert diag.security_count >= 1 + items = diag.by_category().get("security", []) + assert any("critical hidden characters" in i.message for i in items) + + class TestOpenCodeCommandIntegration: """Tests for OpenCode command integration.""" @@ -479,6 +521,285 @@ def test_claude_target_dispatches_commands(self): shutil.rmtree(temp_dir, ignore_errors=True) +class TestExtractInputNames: + """Tests for _extract_input_names helper.""" + + def test_none(self): + assert _extract_input_names(None) == [] + + def test_string(self): + assert _extract_input_names("name") == ["name"] + + def test_simple_list(self): + assert _extract_input_names(["a", "b", "c"]) == ["a", "b", "c"] + + def test_object_list(self): + result = _extract_input_names([ + {"feature_name": "Name"}, + {"desc": "Description"}, + ]) + assert result == ["feature_name", "desc"] + + def test_mixed_list(self): + result = _extract_input_names([ + "simple_arg", + {"complex_arg": "A complex argument"}, + ]) + assert result == ["simple_arg", "complex_arg"] + + def test_bare_dict(self): + result = _extract_input_names({"a": "desc a", "b": "desc b"}) + assert result == ["a", "b"] + + def test_empty_string(self): + assert _extract_input_names("") == [] + + def test_whitespace_only_string(self): + assert _extract_input_names(" ") == [] + + def test_empty_strings_in_list(self): + result = _extract_input_names(["name", "", " ", "category"]) + assert result == ["name", "category"] + + def test_empty_keys_in_dict(self): + result = _extract_input_names({"": "empty", "name": "ok"}) + assert result == ["name"] + + def test_empty_keys_in_object_list(self): + result = _extract_input_names([{"": "empty"}, {"name": "ok"}]) + assert result == ["name"] + + +class TestInputToArgumentsEndToEnd: + """Full dispatch-layer test: .prompt.md with input -> Claude arguments. + + Exercises integrate_package_primitives with real (non-mocked) integrators + to verify the input-to-arguments mapping survives the full install path. + """ + + @pytest.fixture + def temp_project(self): + temp_dir = tempfile.mkdtemp() + temp_path = Path(temp_dir) + (temp_path / ".claude").mkdir() + yield temp_path + shutil.rmtree(temp_dir, ignore_errors=True) + + def _make_package(self, project_root, prompts): + pkg_dir = project_root / "apm_modules" / "test-pkg" + pkg_dir.mkdir(parents=True) + prompts_dir = pkg_dir / ".apm" / "prompts" + prompts_dir.mkdir(parents=True) + for name, content in prompts.items(): + (prompts_dir / name).write_text(content) + + mock_info = MagicMock() + mock_info.install_path = pkg_dir + mock_info.resolved_reference = None + mock_info.package = MagicMock() + mock_info.package.name = "test-pkg" + return mock_info + + def test_full_dispatch_maps_input_to_arguments(self, temp_project): + """input: [name, category] produces Claude arguments via full dispatch.""" + from apm_cli.install.services import integrate_package_primitives + from apm_cli.integration.targets import KNOWN_TARGETS + from apm_cli.integration import ( + PromptIntegrator, + AgentIntegrator, + SkillIntegrator, + InstructionIntegrator, + HookIntegrator, + ) + from apm_cli.utils.diagnostics import DiagnosticCollector + + pkg_info = self._make_package(temp_project, { + "gen.prompt.md": ( + "---\n" + "description: Generate something\n" + "input: [name, category]\n" + "---\n" + "Create ${{input:name}} in ${{input:category}}.\n" + ), + }) + + result = integrate_package_primitives( + pkg_info, + temp_project, + targets=[KNOWN_TARGETS["claude"]], + prompt_integrator=PromptIntegrator(), + agent_integrator=AgentIntegrator(), + skill_integrator=SkillIntegrator(), + instruction_integrator=InstructionIntegrator(), + command_integrator=CommandIntegrator(), + hook_integrator=HookIntegrator(), + force=False, + managed_files=set(), + diagnostics=DiagnosticCollector(), + ) + + assert result["commands"] == 1 + + target = temp_project / ".claude" / "commands" / "gen.md" + assert target.exists() + post = frontmatter.load(target) + assert post.metadata["arguments"] == ["name", "category"] + assert post.metadata["argument-hint"] == " " + assert "$name" in post.content + assert "$category" in post.content + assert "${{input:" not in post.content + + +class TestInputToArgumentsIntegration: + """Integrator-level test: .prompt.md with input -> Claude arguments front-matter.""" + + @pytest.fixture + def temp_project(self): + temp_dir = tempfile.mkdtemp() + temp_path = Path(temp_dir) + (temp_path / ".claude").mkdir() + yield temp_path + shutil.rmtree(temp_dir, ignore_errors=True) + + def _make_package(self, project_root, prompts): + pkg_dir = project_root / "apm_modules" / "test-pkg" + pkg_dir.mkdir(parents=True) + prompts_dir = pkg_dir / ".apm" / "prompts" + prompts_dir.mkdir(parents=True) + for name, content in prompts.items(): + (prompts_dir / name).write_text(content) + + mock_info = MagicMock() + mock_info.install_path = pkg_dir + mock_info.resolved_reference = None + mock_info.package = MagicMock() + mock_info.package.name = "test-pkg" + return mock_info + + def test_input_list_becomes_arguments(self, temp_project): + """input: [name, category] maps to arguments: [name, category].""" + pkg_info = self._make_package(temp_project, { + "gen.prompt.md": ( + "---\n" + "description: Generate something\n" + "input: [name, category]\n" + "---\n" + "Create ${{input:name}} in ${{input:category}}.\n" + ), + }) + integrator = CommandIntegrator() + from apm_cli.integration.targets import KNOWN_TARGETS + integrator.integrate_commands_for_target( + KNOWN_TARGETS["claude"], pkg_info, temp_project, + ) + + target = temp_project / ".claude" / "commands" / "gen.md" + assert target.exists() + + post = frontmatter.load(target) + assert post.metadata["arguments"] == ["name", "category"] + assert post.metadata["argument-hint"] == " " + assert "$name" in post.content + assert "$category" in post.content + assert "${{input:" not in post.content + + def test_input_object_list_becomes_arguments(self, temp_project): + """input as object list extracts keys as argument names.""" + pkg_info = self._make_package(temp_project, { + "feat.prompt.md": ( + "---\n" + "description: Feature generator\n" + "input:\n" + " - feature_name: Name of the feature\n" + " - feature_desc: Description\n" + "---\n" + "Build ${{input:feature_name}}: ${{input:feature_desc}}\n" + ), + }) + integrator = CommandIntegrator() + from apm_cli.integration.targets import KNOWN_TARGETS + integrator.integrate_commands_for_target( + KNOWN_TARGETS["claude"], pkg_info, temp_project, + ) + + target = temp_project / ".claude" / "commands" / "feat.md" + post = frontmatter.load(target) + assert post.metadata["arguments"] == ["feature_name", "feature_desc"] + assert "$feature_name" in post.content + assert "$feature_desc" in post.content + + def test_explicit_argument_hint_not_overridden(self, temp_project): + """When argument-hint is already set, input does not override it.""" + pkg_info = self._make_package(temp_project, { + "cmd.prompt.md": ( + "---\n" + "description: A command\n" + "argument-hint: \n" + "input: [x]\n" + "---\n" + "Do ${{input:x}}.\n" + ), + }) + integrator = CommandIntegrator() + from apm_cli.integration.targets import KNOWN_TARGETS + integrator.integrate_commands_for_target( + KNOWN_TARGETS["claude"], pkg_info, temp_project, + ) + + target = temp_project / ".claude" / "commands" / "cmd.md" + post = frontmatter.load(target) + assert post.metadata["argument-hint"] == "" + assert post.metadata["arguments"] == ["x"] + + def test_bare_dict_input_becomes_arguments(self, temp_project): + """input: {a: 'desc'} (bare dict) maps to arguments: [a].""" + pkg_info = self._make_package(temp_project, { + "d.prompt.md": ( + "---\n" + "description: Dict input\n" + "input:\n" + " feature-name: Name of the feature\n" + " feature-desc: Description\n" + "---\n" + "Build ${{input:feature-name}}: ${{input:feature-desc}}\n" + ), + }) + integrator = CommandIntegrator() + from apm_cli.integration.targets import KNOWN_TARGETS + integrator.integrate_commands_for_target( + KNOWN_TARGETS["claude"], pkg_info, temp_project, + ) + + target = temp_project / ".claude" / "commands" / "d.md" + post = frontmatter.load(target) + assert post.metadata["arguments"] == ["feature-name", "feature-desc"] + assert "$feature-name" in post.content + assert "$feature-desc" in post.content + assert "${{input:" not in post.content + + def test_hyphenated_input_names_substituted(self, temp_project): + """Hyphenated names like feature-name are replaced in content.""" + pkg_info = self._make_package(temp_project, { + "h.prompt.md": ( + "---\n" + "description: Hyphen test\n" + "input: [my-arg]\n" + "---\n" + "Use ${{input:my-arg}} here.\n" + ), + }) + integrator = CommandIntegrator() + from apm_cli.integration.targets import KNOWN_TARGETS + integrator.integrate_commands_for_target( + KNOWN_TARGETS["claude"], pkg_info, temp_project, + ) + + target = temp_project / ".claude" / "commands" / "h.md" + post = frontmatter.load(target) + assert "$my-arg" in post.content + assert "${{input:my-arg}}" not in post.content + + # =================================================================== # Gemini CLI Command Integration (.toml format) # =================================================================== diff --git a/tests/unit/test_install_scanning.py b/tests/unit/test_install_scanning.py index 6e672b257..fd71ad5f5 100644 --- a/tests/unit/test_install_scanning.py +++ b/tests/unit/test_install_scanning.py @@ -286,19 +286,3 @@ def test_merge_results_clean_stays_clean(self): merged = compiler._merge_results([r1, r2]) assert merged.has_critical_security is False - def test_command_generation_result_propagates_critical(self): - from apm_cli.compilation.claude_formatter import CommandGenerationResult - r = CommandGenerationResult( - success=True, commands_generated={}, commands_dir=Path("."), - files_written=0, - has_critical_security=True, - ) - assert r.has_critical_security is True - - def test_command_generation_result_defaults_false(self): - from apm_cli.compilation.claude_formatter import CommandGenerationResult - r = CommandGenerationResult( - success=True, commands_generated={}, commands_dir=Path("."), - files_written=0, - ) - assert r.has_critical_security is False From 4aef3ef5f641c189b5fd87bb626e9ec1d78a9e17 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 00:27:58 +0200 Subject: [PATCH 2/3] fix(install): surface non-critical security findings and scan errors Address PR #1039 review feedback: - Emit a warning diagnostic when SecurityGate finds non-critical issues (previously only critical findings were surfaced). - Include critical/warning counts in the surfaced message. - Replace silent 'pass' on scan errors with a warning diagnostic so installs remain observable when ImportError/OSError/ValueError occurs. - Add a unit test covering the warning-only findings path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/apm_cli/integration/command_integrator.py | 14 +++++++-- .../integration/test_command_integrator.py | 29 ++++++++++++++++++- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/apm_cli/integration/command_integrator.py b/src/apm_cli/integration/command_integrator.py index 72b5e3d8c..dfd9f8b0e 100644 --- a/src/apm_cli/integration/command_integrator.py +++ b/src/apm_cli/integration/command_integrator.py @@ -175,11 +175,19 @@ def integrate_command( verdict = SecurityGate.scan_text(compiled, str(target), policy=WARN_POLICY) if verdict.has_critical: warnings.append( - f"{target.name}: critical hidden characters " + f"{target.name}: {verdict.critical_count} critical, " + f"{verdict.warning_count} warning(s) hidden character finding(s) " f"-- run 'apm audit --file {target}' to inspect" ) - except (ImportError, OSError, ValueError): - pass + elif verdict.has_findings: + warnings.append( + f"{target.name}: {verdict.warning_count} warning(s) hidden " + f"character finding(s) -- run 'apm audit --file {target}' to inspect" + ) + except (ImportError, OSError, ValueError) as exc: + warnings.append( + f"{target.name}: security scan skipped due to scan error: {exc}" + ) # Surface any collected warnings pkg_name = getattr( diff --git a/tests/unit/integration/test_command_integrator.py b/tests/unit/integration/test_command_integrator.py index 232f8a8eb..011904abf 100644 --- a/tests/unit/integration/test_command_integrator.py +++ b/tests/unit/integration/test_command_integrator.py @@ -307,7 +307,34 @@ def test_critical_chars_recorded_in_diagnostics(self, temp_project): assert diag.security_count >= 1 items = diag.by_category().get("security", []) - assert any("critical hidden characters" in i.message for i in items) + assert any("critical" in i.message for i in items) + + def test_warning_only_findings_recorded_in_diagnostics(self, temp_project): + """SecurityGate warning-only findings (e.g. soft hyphen) also surface.""" + from apm_cli.utils.diagnostics import DiagnosticCollector + + source = temp_project / "source" / "warn.prompt.md" + # U+00AD soft hyphen is classified as a 'warning', not critical. + source.write_text( + "---\ndescription: Warn\n---\nSoft\u00adhyphen here.\n", + encoding="utf-8", + ) + target = temp_project / ".claude" / "commands" / "warn.md" + + mock_info = MagicMock() + mock_info.package = MagicMock() + mock_info.package.name = "warn-pkg" + mock_info.resolved_reference = None + + diag = DiagnosticCollector() + integrator = CommandIntegrator() + integrator.integrate_command( + source, target, mock_info, source, diagnostics=diag, + ) + + assert diag.security_count >= 1 + items = diag.by_category().get("security", []) + assert any("warning" in i.message.lower() for i in items) class TestOpenCodeCommandIntegration: From 55b05e40481d4896ada50a3662d2c2b34b8d1fa3 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 00:34:20 +0200 Subject: [PATCH 3/3] fix(install): address APM Review Panel required findings for #1039 - Fail-closed on missing security gate (re-raise ImportError so no file is written) - Validate input names against ^[A-Za-z][\w-]{0,63}$ to prevent YAML injection via attacker-controlled dict keys; rejected names are dropped + warned - Route critical security findings with severity='critical' (not downgraded to 'warning') so they render red+bold in install diagnostics - Split short message vs actionable detail in security/warn diagnostics - Type diagnostics parameter as Optional[DiagnosticCollector] (TYPE_CHECKING import to avoid runtime cycle) and drop hasattr guard - Emit install-time info diagnostic listing arguments mapped per file so the input -> Claude arguments transformation is visible without --verbose - Route non-security warnings via diagnostics.warn() not .security() to avoid miscategorisation in the rendered output - Add tests: YAML-injection rejection, single-brace ${input:name} fixture, install-time info diagnostic, and ImportError fail-closed - Document the install-time mapping under apm install in cli-commands.md - Move CHANGELOG entry from Fixed to Added and rewrite outcome-first Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 +- .../content/docs/reference/cli-commands.md | 12 + src/apm_cli/integration/command_integrator.py | 168 ++++++++++--- .../integration/test_command_integrator.py | 220 ++++++++++++++++-- 4 files changed, 345 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73766a571..668f7c1f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Dev Container Feature** `ghcr.io/microsoft/apm/apm-cli` -- one-line install of the APM CLI into any `devcontainer.json`, GitHub Codespace, or JetBrains Gateway workspace. Supports a `version` option (`latest` or pinned semver), declares `installsAfter` for the official Python feature, handles PEP 668 on Ubuntu 24.04+. Ships with 37 bats unit tests and a 6-distro Docker integration matrix (Ubuntu 24.04, Ubuntu 22.04, Debian 12, Alpine 3.20, Fedora 41, plus Python-feature combo). (#861) - `shared/apm.md` gh-aw workflow gains an `apps:` array input for cross-org private packages: each entry mints its own GitHub App installation token via `actions/create-github-app-token` and packs only its declared packages, with a matrix fan-out one replica per credential group. The single-app top-level form (`app-id`, `private-key`, `owner`, `repositories`) shipped earlier in this cycle is preserved as the canonical shorthand for one-org users; `apps[]` is purely additive. Multi-bundle restore uses the `bundles-file:` input from `microsoft/apm-action@v1.5.0` (microsoft/apm-action#30, microsoft/apm-action#29). - `shared/apm.md` gh-aw workflow now accepts `app-id`, `private-key`, `owner`, and `repositories` inputs to mint a GitHub App installation token for fetching cross-org private APM packages, restoring parity with the deprecated `dependencies.github-app` form. The default `GH_AW_PLUGINS_TOKEN || GH_AW_GITHUB_TOKEN || GITHUB_TOKEN` cascade still applies when no app-id is supplied. +- Slash commands installed from APM packages now surface argument hints in Claude Code -- `apm install` automatically maps prompt `input:` to Claude's `arguments:` front-matter, rewrites `${input:name}` references to `$name`, and auto-generates `argument-hint`. Argument names are validated against an allowlist to prevent YAML injection from third-party packages, and the mapping is reported at install time. (#1039) ### Changed @@ -22,7 +23,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- `apm install` now maps APM prompt `input:` frontmatter to Claude `arguments:` frontmatter, converts `${input:name}` references to `$name` placeholders, and auto-generates `argument-hint` -- slash commands installed from APM packages now surface argument hints in Claude Code. (#1039) - `apm install` and `apm compile` no longer exit 0 with success messages when `target:` in `apm.yml` is a CSV string -- the value now parses identically to the same input on `--target`, and zero-target resolution surfaces a warning instead of a silent no-op. (#820) - Remove redundant `seen` set from `_scan_patterns()` discovery walk (#918) - `apm marketplace build` now respects `GITHUB_HOST` for GitHub Enterprise repos -- ref resolution, token lookup, and metadata fetch all use the configured host instead of hardcoded `github.com`. `git ls-remote` is authenticated so private repos work without separate credential setup. (#1008) diff --git a/docs/src/content/docs/reference/cli-commands.md b/docs/src/content/docs/reference/cli-commands.md index aa7617c87..335b67f87 100644 --- a/docs/src/content/docs/reference/cli-commands.md +++ b/docs/src/content/docs/reference/cli-commands.md @@ -128,6 +128,18 @@ See [Dependencies: Transport selection](../../guides/dependencies/#transport-sel - Each `http://` dependency is warned at install time before any fetch begins - Transitive `http://` dependencies are allowed automatically when they use the same host as a direct insecure dependency you approved with `--allow-insecure`; other transitive hosts require `--allow-insecure-host HOSTNAME` +**Claude Code: prompt `input:` -> slash command `arguments:`:** + +When installing into `.claude/commands/`, prompt files with an `input:` front-matter key are transformed so Claude Code can surface typed argument hints in the slash-command picker: + +- `input:` is mapped to Claude's `arguments:` front-matter (preserving order). +- An `argument-hint:` is auto-generated as ` ...` unless the prompt already sets one explicitly. +- `${input:name}` references in the body are rewritten to Claude-style `$name` placeholders (double-brace `${{input:name}}` is also accepted). +- Argument names are restricted to `^[A-Za-z][\w-]{0,63}$`; names containing YAML-significant characters are rejected with a warning and dropped from the output. +- A short install-time message lists the mapped arguments per file so the transformation is visible without `--verbose`. + +This transformation only applies to the `claude` target. Other targets receive the prompt content unchanged. + **Local `.apm/` Content Deployment:** After integrating dependencies, `apm install` deploys primitives from the project's own `.apm/` directory (instructions, prompts, agents, skills, hooks, commands) to target directories (`.github/`, `.claude/`, `.cursor/`, etc.). Local content takes priority over dependencies on collision. Deployed files are tracked in the lockfile for cleanup on subsequent installs. This works even with zero dependencies -- just `apm.yml` and `.apm/` content is enough. diff --git a/src/apm_cli/integration/command_integrator.py b/src/apm_cli/integration/command_integrator.py index dfd9f8b0e..c7939120e 100644 --- a/src/apm_cli/integration/command_integrator.py +++ b/src/apm_cli/integration/command_integrator.py @@ -13,15 +13,31 @@ import frontmatter from apm_cli.integration.base_integrator import BaseIntegrator, IntegrationResult +from apm_cli.security.gate import WARN_POLICY, SecurityGate from apm_cli.utils.paths import portable_relpath if TYPE_CHECKING: from apm_cli.integration.targets import TargetProfile + from apm_cli.utils.diagnostics import DiagnosticCollector logger = logging.getLogger(__name__) -def _extract_input_names(input_spec: Any) -> List[str]: +# Allowlist for argument names extracted from package-supplied 'input:' front-matter. +# Restricts to identifiers that are safe to embed in YAML frontmatter and in +# Claude command bodies as $name placeholders. Rejects YAML-significant +# characters (newline, colon, quote, etc.) to prevent frontmatter injection. +_INPUT_NAME_RE = re.compile(r"^[A-Za-z][\w-]{0,63}$") + + +def _is_valid_input_name(name: str) -> bool: + """Return True if *name* is a safe argument identifier.""" + return bool(_INPUT_NAME_RE.match(name)) + + +def _extract_input_names( + input_spec: Any, +) -> Tuple[List[str], List[str]]: """Extract argument names from an APM 'input' front-matter value. Handles both formats: @@ -34,28 +50,50 @@ def _extract_input_names(input_spec: Any) -> List[str]: input_spec: The raw value of the 'input' front-matter key. Returns: - List[str]: Ordered list of argument names, or empty list. + Tuple[List[str], List[str]]: (valid names in order, rejected raw entries). + Names are accepted only if they match ``^[A-Za-z][\\w-]{0,63}$``; + anything else (empty/whitespace, YAML-significant chars, oversize) is + rejected and reported back so the caller can surface a warning. """ + valid: List[str] = [] + rejected: List[str] = [] + + def _accept(candidate: Any) -> None: + if not isinstance(candidate, str): + rejected.append(repr(candidate)) + return + stripped = candidate.strip() + if not stripped: + return # silently drop pure-whitespace entries + if _is_valid_input_name(stripped): + valid.append(stripped) + else: + rejected.append(stripped) + if input_spec is None: - return [] + return valid, rejected if isinstance(input_spec, list): - names: List[str] = [] for item in input_spec: if isinstance(item, str): - if item.strip(): - names.append(item) + _accept(item) elif isinstance(item, dict): - names.extend(k for k in item.keys() if k.strip()) - return names + for k in item.keys(): + _accept(k) + else: + rejected.append(repr(item)) + return valid, rejected if isinstance(input_spec, str): - return [input_spec] if input_spec.strip() else [] + _accept(input_spec) + return valid, rejected if isinstance(input_spec, dict): - return [k for k in input_spec.keys() if k.strip()] + for k in input_spec.keys(): + _accept(k) + return valid, rejected - return [] + return valid, rejected # Re-export for backward compat (tests import CommandIntegrationResult) @@ -118,7 +156,14 @@ def _transform_prompt_to_command( claude_metadata['argument-hint'] = post.metadata['argumentHint'] # Map APM 'input' to Claude 'arguments' and 'argument-hint' - input_names = _extract_input_names(post.metadata.get('input')) + input_names, rejected_names = _extract_input_names(post.metadata.get('input')) + if rejected_names: + warnings.append( + f"input: rejected {len(rejected_names)} invalid name(s) " + f"(must match [A-Za-z][\\w-]{{0,63}}): " + f"{', '.join(rejected_names[:5])}" + + (" ..." if len(rejected_names) > 5 else "") + ) if input_names: claude_metadata['arguments'] = input_names if 'argument-hint' not in claude_metadata: @@ -148,7 +193,7 @@ def integrate_command( package_info: Any, original_path: Path, *, - diagnostics: Any = None, + diagnostics: Optional["DiagnosticCollector"] = None, ) -> int: """Integrate a prompt file as a Claude command (verbatim copy with format conversion). @@ -168,38 +213,89 @@ def integrate_command( # Resolve context links in content post.content, links_resolved = self.resolve_links(post.content, source, target) - # Defense-in-depth: scan compiled command before writing + pkg_name = getattr( + getattr(package_info, "package", None), "name", "", + ) + + # Surface install-time info when input -> arguments mapping happened so + # users aren't surprised by content that differs from the source package. + mapped_args = post.metadata.get("arguments") if post.metadata else None + if mapped_args and diagnostics is not None: + diagnostics.info( + message=( + f"Mapped input -> Claude arguments in {target.name}: " + f"[{', '.join(mapped_args)}]" + ), + package=pkg_name, + detail=( + f"${{input:name}} references in {source.name} were rewritten " + f"to $name and 'argument-hint' was generated unless explicitly set." + ), + ) + + # Defense-in-depth: scan compiled command before writing. + # Fail-closed on missing/broken security gate (re-raise ImportError); + # other I/O-style errors are surfaced as a warning so installs stay observable. compiled = frontmatter.dumps(post) + scan_verdict = None try: - from apm_cli.security.gate import WARN_POLICY, SecurityGate - verdict = SecurityGate.scan_text(compiled, str(target), policy=WARN_POLICY) - if verdict.has_critical: - warnings.append( - f"{target.name}: {verdict.critical_count} critical, " - f"{verdict.warning_count} warning(s) hidden character finding(s) " - f"-- run 'apm audit --file {target}' to inspect" - ) - elif verdict.has_findings: - warnings.append( - f"{target.name}: {verdict.warning_count} warning(s) hidden " - f"character finding(s) -- run 'apm audit --file {target}' to inspect" - ) - except (ImportError, OSError, ValueError) as exc: + scan_verdict = SecurityGate.scan_text( + compiled, str(target), policy=WARN_POLICY, + ) + except ImportError: + # Missing/tampered gate must not silently become a no-op. + raise + except (OSError, ValueError) as exc: warnings.append( f"{target.name}: security scan skipped due to scan error: {exc}" ) - # Surface any collected warnings - pkg_name = getattr( - getattr(package_info, "package", None), "name", "", - ) - for warning in warnings: - if diagnostics and hasattr(diagnostics, "security"): + security_messages: List[Tuple[str, str, str]] = [] + if scan_verdict is not None: + if scan_verdict.has_critical: + security_messages.append( + ( + f"Critical hidden characters in {target.name}", + ( + f"{scan_verdict.critical_count} critical, " + f"{scan_verdict.warning_count} warning(s) -- " + f"run 'apm audit --file {target}' to inspect" + ), + "critical", + ) + ) + elif scan_verdict.has_findings: + security_messages.append( + ( + f"Hidden character warnings in {target.name}", + ( + f"{scan_verdict.warning_count} warning(s) -- " + f"run 'apm audit --file {target}' to inspect" + ), + "warning", + ) + ) + + # Surface security findings via diagnostics.security() with correct severity. + for message, detail, severity in security_messages: + if diagnostics is not None: diagnostics.security( + message=message, + package=pkg_name, + detail=detail, + severity=severity, + ) + else: + logger.warning("%s: %s", message, detail) + + # Surface non-security warnings (e.g. parse / scan-error / rejected + # input names) via the general warning channel so they don't get + # miscategorized as security findings. + for warning in warnings: + if diagnostics is not None: + diagnostics.warn( message=warning, package=pkg_name, - detail=warning, - severity="warning", ) else: logger.warning(warning) diff --git a/tests/unit/integration/test_command_integrator.py b/tests/unit/integration/test_command_integrator.py index 011904abf..6ed2d4fae 100644 --- a/tests/unit/integration/test_command_integrator.py +++ b/tests/unit/integration/test_command_integrator.py @@ -307,7 +307,12 @@ def test_critical_chars_recorded_in_diagnostics(self, temp_project): assert diag.security_count >= 1 items = diag.by_category().get("security", []) - assert any("critical" in i.message for i in items) + # Critical findings must land in the critical bucket (severity), and + # the short message must read as critical (not be downgraded). + assert any( + i.severity == "critical" and "critical" in i.message.lower() + for i in items + ) def test_warning_only_findings_recorded_in_diagnostics(self, temp_project): """SecurityGate warning-only findings (e.g. soft hyphen) also surface.""" @@ -332,9 +337,8 @@ def test_warning_only_findings_recorded_in_diagnostics(self, temp_project): source, target, mock_info, source, diagnostics=diag, ) - assert diag.security_count >= 1 items = diag.by_category().get("security", []) - assert any("warning" in i.message.lower() for i in items) + assert any(i.severity == "warning" for i in items) class TestOpenCodeCommandIntegration: @@ -552,49 +556,84 @@ class TestExtractInputNames: """Tests for _extract_input_names helper.""" def test_none(self): - assert _extract_input_names(None) == [] + assert _extract_input_names(None) == ([], []) def test_string(self): - assert _extract_input_names("name") == ["name"] + assert _extract_input_names("name") == (["name"], []) def test_simple_list(self): - assert _extract_input_names(["a", "b", "c"]) == ["a", "b", "c"] + assert _extract_input_names(["a", "b", "c"]) == (["a", "b", "c"], []) def test_object_list(self): - result = _extract_input_names([ + valid, rejected = _extract_input_names([ {"feature_name": "Name"}, {"desc": "Description"}, ]) - assert result == ["feature_name", "desc"] + assert valid == ["feature_name", "desc"] + assert rejected == [] def test_mixed_list(self): - result = _extract_input_names([ + valid, rejected = _extract_input_names([ "simple_arg", {"complex_arg": "A complex argument"}, ]) - assert result == ["simple_arg", "complex_arg"] + assert valid == ["simple_arg", "complex_arg"] + assert rejected == [] def test_bare_dict(self): - result = _extract_input_names({"a": "desc a", "b": "desc b"}) - assert result == ["a", "b"] + valid, rejected = _extract_input_names({"a": "desc a", "b": "desc b"}) + assert valid == ["a", "b"] + assert rejected == [] def test_empty_string(self): - assert _extract_input_names("") == [] + assert _extract_input_names("") == ([], []) def test_whitespace_only_string(self): - assert _extract_input_names(" ") == [] + assert _extract_input_names(" ") == ([], []) def test_empty_strings_in_list(self): - result = _extract_input_names(["name", "", " ", "category"]) - assert result == ["name", "category"] + valid, _ = _extract_input_names(["name", "", " ", "category"]) + assert valid == ["name", "category"] def test_empty_keys_in_dict(self): - result = _extract_input_names({"": "empty", "name": "ok"}) - assert result == ["name"] + valid, _ = _extract_input_names({"": "empty", "name": "ok"}) + assert valid == ["name"] def test_empty_keys_in_object_list(self): - result = _extract_input_names([{"": "empty"}, {"name": "ok"}]) - assert result == ["name"] + valid, _ = _extract_input_names([{"": "empty"}, {"name": "ok"}]) + assert valid == ["name"] + + def test_yaml_injection_dict_key_rejected(self): + """A dict key with YAML-significant characters must be rejected.""" + malicious = {"foo>\ninjected_key": "desc"} + valid, rejected = _extract_input_names(malicious) + assert valid == [] + assert any("injected_key" in r for r in rejected) + + def test_yaml_injection_list_string_rejected(self): + """A list string with newline/colon must be rejected.""" + valid, rejected = _extract_input_names(["good", "bad: name", "evil\nkey"]) + assert valid == ["good"] + assert "bad: name" in rejected + assert "evil\nkey" in rejected + + def test_leading_digit_rejected(self): + """Names must start with a letter.""" + valid, rejected = _extract_input_names(["1bad", "good"]) + assert valid == ["good"] + assert "1bad" in rejected + + def test_overlong_name_rejected(self): + """Names over 64 chars (1 + 63) are rejected.""" + long_name = "a" + "b" * 64 + valid, rejected = _extract_input_names([long_name, "ok"]) + assert valid == ["ok"] + assert long_name in rejected + + def test_hyphenated_name_accepted(self): + valid, rejected = _extract_input_names(["my-arg"]) + assert valid == ["my-arg"] + assert rejected == [] class TestInputToArgumentsEndToEnd: @@ -826,6 +865,147 @@ def test_hyphenated_input_names_substituted(self, temp_project): assert "$my-arg" in post.content assert "${{input:my-arg}}" not in post.content + def test_single_brace_input_references_substituted(self, temp_project): + """${input:name} (single-brace, the canonical docs format) is rewritten.""" + pkg_info = self._make_package(temp_project, { + "s.prompt.md": ( + "---\n" + "description: Single-brace test\n" + "input: [name, category]\n" + "---\n" + "Create ${input:name} in ${input:category}.\n" + ), + }) + integrator = CommandIntegrator() + from apm_cli.integration.targets import KNOWN_TARGETS + integrator.integrate_commands_for_target( + KNOWN_TARGETS["claude"], pkg_info, temp_project, + ) + + target = temp_project / ".claude" / "commands" / "s.md" + post = frontmatter.load(target) + assert post.metadata["arguments"] == ["name", "category"] + assert "$name" in post.content + assert "$category" in post.content + assert "${input:" not in post.content + + +class TestInputMappingDiagnostics: + """Verify install-time visibility when input -> arguments mapping happens.""" + + @pytest.fixture + def temp_project(self): + temp_dir = tempfile.mkdtemp() + temp_path = Path(temp_dir) + (temp_path / "source").mkdir() + (temp_path / ".claude" / "commands").mkdir(parents=True) + yield temp_path + shutil.rmtree(temp_dir, ignore_errors=True) + + def test_mapping_emits_info_diagnostic(self, temp_project): + """When input is mapped, an info-level diagnostic is recorded.""" + from apm_cli.utils.diagnostics import DiagnosticCollector + + source = temp_project / "source" / "review.prompt.md" + source.write_text( + "---\ndescription: Review\ninput: [feature_name, priority]\n---\n" + "Review ${input:feature_name} priority ${input:priority}.\n", + encoding="utf-8", + ) + target = temp_project / ".claude" / "commands" / "review.md" + + mock_info = MagicMock() + mock_info.package = MagicMock() + mock_info.package.name = "test-pkg" + mock_info.resolved_reference = None + + diag = DiagnosticCollector() + CommandIntegrator().integrate_command( + source, target, mock_info, source, diagnostics=diag, + ) + + info_items = diag.by_category().get("info", []) + assert any( + "Mapped input -> Claude arguments" in i.message + and "feature_name" in i.message + and "priority" in i.message + for i in info_items + ) + + def test_yaml_injection_attempt_warns(self, temp_project): + """A package supplying a YAML-injecting dict key gets a warn diagnostic and the key is dropped.""" + from apm_cli.utils.diagnostics import DiagnosticCollector + + source = temp_project / "source" / "evil.prompt.md" + # The first entry contains a newline+colon that would inject a new key + # if written verbatim into YAML; the allowlist must reject it. + source.write_text( + "---\n" + "description: Evil\n" + "input:\n" + " - \"foo>\\ninjected_key\": bad\n" + " - good_arg: ok\n" + "---\n" + "body\n", + encoding="utf-8", + ) + target = temp_project / ".claude" / "commands" / "evil.md" + + mock_info = MagicMock() + mock_info.package = MagicMock() + mock_info.package.name = "evil-pkg" + mock_info.resolved_reference = None + + diag = DiagnosticCollector() + CommandIntegrator().integrate_command( + source, target, mock_info, source, diagnostics=diag, + ) + + post = frontmatter.load(target) + assert post.metadata["arguments"] == ["good_arg"] + warn_items = diag.by_category().get("warning", []) + assert any("rejected" in w.message for w in warn_items) + + +class TestSecurityScanFailClosed: + """Verify the security scan fails closed when the gate cannot be loaded.""" + + @pytest.fixture + def temp_project(self): + temp_dir = tempfile.mkdtemp() + temp_path = Path(temp_dir) + (temp_path / "source").mkdir() + (temp_path / ".claude" / "commands").mkdir(parents=True) + yield temp_path + shutil.rmtree(temp_dir, ignore_errors=True) + + def test_import_error_re_raised(self, temp_project, monkeypatch): + """ImportError from SecurityGate.scan_text must propagate (fail closed).""" + from apm_cli.integration import command_integrator as ci + + def boom(*args, **kwargs): + raise ImportError("simulated missing gate") + + monkeypatch.setattr(ci.SecurityGate, "scan_text", boom) + + source = temp_project / "source" / "x.prompt.md" + source.write_text( + "---\ndescription: X\n---\nbody\n", encoding="utf-8", + ) + target = temp_project / ".claude" / "commands" / "x.md" + + mock_info = MagicMock() + mock_info.package = MagicMock() + mock_info.package.name = "p" + mock_info.resolved_reference = None + + with pytest.raises(ImportError): + CommandIntegrator().integrate_command( + source, target, mock_info, source, + ) + # Fail-closed: file must NOT have been written. + assert not target.exists() + # =================================================================== # Gemini CLI Command Integration (.toml format)