From f7ebc399480fd5037e8d3b743b538989b4598a85 Mon Sep 17 00:00:00 2001 From: rdwj Date: Mon, 4 May 2026 12:14:36 -0500 Subject: [PATCH 1/2] chore: black format drift in test fixtures Triggered by `black src tests` during Session A; unrelated to the patch-for-agents work that follows. Assisted-by: Claude Code (Opus 4.7) --- tests/conftest.py | 6 ++++-- tests/test_generators.py | 12 ++++++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 4774697..9752076 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -28,13 +28,15 @@ def mock_template_repo(temp_dir): template_dir.mkdir() # Create basic template structure - (template_dir / "pyproject.toml").write_text("""[project] + (template_dir / "pyproject.toml").write_text( + """[project] name = "mcp-server-template" version = "0.1.0" [project.scripts] mcp-server-template = "mcp_server_template.server:main" -""") +""" + ) # Create src directory with module src_dir = template_dir / "src" / "mcp_server_template" diff --git a/tests/test_generators.py b/tests/test_generators.py index 39f3207..792a2d2 100644 --- a/tests/test_generators.py +++ b/tests/test_generators.py @@ -304,10 +304,12 @@ def test_run_tests_success(self, tmp_path): """Test running tests that pass.""" # Create a simple passing test test_file = tmp_path / "test_example.py" - test_file.write_text(""" + test_file.write_text( + """ def test_passing(): assert True -""") +""" + ) success, output = run_component_tests(tmp_path, test_file) @@ -320,10 +322,12 @@ def test_run_tests_failure(self, tmp_path): """Test running tests that fail.""" # Create a failing test test_file = tmp_path / "test_example.py" - test_file.write_text(""" + test_file.write_text( + """ def test_failing(): assert False, "This test should fail" -""") +""" + ) success, output = run_component_tests(tmp_path, test_file) From 08e66fec05fdab95eb2da1bd3ce5bc2d1f9c2418 Mon Sep 17 00:00:00 2001 From: rdwj Date: Mon, 4 May 2026 12:14:56 -0500 Subject: [PATCH 2/2] feat: Extend patch command to support agent projects (#12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The patch flow previously only worked for MCP server projects: it sniffed for a fastmcp dependency to find the project root, hardcoded MCP-shaped file categories, and cloned templates as standalone repos. None of that fits the agent template, which lives in a monorepo subdir and has a chart/-centric layout. This change makes patch type-aware end-to-end: - `.template-info` now records `template.type` (and `template.subdir` for monorepo templates) at scaffold time. Pre-existing projects default to "mcp-server" via get_project_type(). (#13) - New find_fips_project_root() walks up to .template-info, working for any scaffolded project. The MCP-only find_project_root() stays for add/generate. (#14) - Categories split into MCP_FILE_CATEGORIES / AGENT_FILE_CATEGORIES with parallel NEVER_PATCH lists. Agent categories are chart, docs, build, claude — matching the real agent-loop layout. No "framework" category: base-agent code lives in fipsagents (PyPI or vendored) and has its own update path. (#15) - _clone_template_for_patch() resolves the template subdir for monorepo templates so glob/compare runs against templates/agent-loop/, not the monorepo root. (#16) - patch.py adds `chart` and `claude` subcommands; `patch all` enumerates the project's actual categories. Running an MCP-only subcommand (e.g. `patch generators`) inside an agent project exits with a type-aware "available: chart, docs, build, claude" message. Tests: new tests/test_patch.py with 20 cases covering helpers, root-finding, subdir cloning, agent-project drift detection, and the end-to-end patch flow including verifying user-customized files (chart/values.yaml, src/agent.py) are never touched. Closes #12, #13, #14, #15, #16. Assisted-by: Claude Code (Opus 4.7) --- src/fips_agents_cli/commands/create.py | 8 + src/fips_agents_cli/commands/patch.py | 77 ++++--- src/fips_agents_cli/tools/patching.py | 182 ++++++++++++--- src/fips_agents_cli/tools/project.py | 22 +- src/fips_agents_cli/tools/validation.py | 43 +++- tests/test_patch.py | 295 ++++++++++++++++++++++++ 6 files changed, 567 insertions(+), 60 deletions(-) create mode 100644 tests/test_patch.py diff --git a/src/fips_agents_cli/commands/create.py b/src/fips_agents_cli/commands/create.py index fd42c73..8f99a49 100644 --- a/src/fips_agents_cli/commands/create.py +++ b/src/fips_agents_cli/commands/create.py @@ -263,6 +263,7 @@ def mcp_server( project_name, MCP_SERVER_TEMPLATE_URL, template_commit, + template_type="mcp-server", github_repo=github_repo, github_url=github_url, ) @@ -545,6 +546,8 @@ def agent( project_name, AGENT_TEMPLATE_URL, template_commit, + template_type="agent", + template_subdir=AGENT_TEMPLATE_SUBDIR, github_repo=github_repo, github_url=github_url, ) @@ -804,6 +807,8 @@ def workflow( project_name, AGENT_TEMPLATE_URL, template_commit, + template_type="workflow", + template_subdir=WORKFLOW_TEMPLATE_SUBDIR, github_repo=github_repo, github_url=github_url, ) @@ -1062,6 +1067,7 @@ def gateway( project_name, GATEWAY_TEMPLATE_URL, template_commit, + template_type="gateway", github_repo=github_repo, github_url=github_url, ) @@ -1320,6 +1326,7 @@ def ui( project_name, UI_TEMPLATE_URL, template_commit, + template_type="ui", github_repo=github_repo, github_url=github_url, ) @@ -1577,6 +1584,7 @@ def sandbox( project_name, SANDBOX_TEMPLATE_URL, template_commit, + template_type="sandbox", github_repo=github_repo, github_url=github_url, ) diff --git a/src/fips_agents_cli/commands/patch.py b/src/fips_agents_cli/commands/patch.py index e706f1f..d928f14 100644 --- a/src/fips_agents_cli/commands/patch.py +++ b/src/fips_agents_cli/commands/patch.py @@ -10,10 +10,10 @@ from fips_agents_cli.tools.patching import ( check_for_updates, get_available_categories, - get_template_info, + get_project_type, patch_category, ) -from fips_agents_cli.tools.validation import find_project_root +from fips_agents_cli.tools.validation import find_fips_project_root console = Console() @@ -33,23 +33,14 @@ def check(): """ console.print("\n[bold cyan]Checking for Template Updates[/bold cyan]\n") - # Find project root - project_root = find_project_root() - if not project_root: + found = find_fips_project_root() + if not found: console.print( "[red]✗[/red] Not in a project directory\n" "[yellow]Hint:[/yellow] Run this command from within a project created by fips-agents" ) sys.exit(1) - - # Get template info - template_info = get_template_info(project_root) - if not template_info: - console.print( - "[red]✗[/red] No template metadata found\n" - "[yellow]Hint:[/yellow] This project may not have been created by fips-agents-cli" - ) - sys.exit(1) + project_root, template_info = found console.print("[green]✓[/green] Project created from template") console.print(f" Template: {template_info['template']['url']}") @@ -134,6 +125,37 @@ def build(dry_run: bool): _patch_category("build", dry_run) +@patch.command("chart") +@click.option( + "--dry-run", + is_flag=True, + help="Show what would be updated without making changes", +) +def chart(dry_run: bool): + """ + Update Helm chart templates (agent / workflow projects only). + + Patches files under chart/templates/ and chart/Chart.yaml. + chart/values.yaml is never patched (user-customized). + """ + _patch_category("chart", dry_run) + + +@patch.command("claude") +@click.option( + "--dry-run", + is_flag=True, + help="Show what would be updated without making changes", +) +def claude(dry_run: bool): + """ + Update Claude Code slash commands (agent / workflow projects only). + + Patches files under .claude/commands/ that ship with the template. + """ + _patch_category("claude", dry_run) + + @patch.command("all") @click.option( "--dry-run", @@ -153,13 +175,23 @@ def all_categories(dry_run: bool, skip_confirmation: bool): """ console.print("\n[bold cyan]Patching All Categories[/bold cyan]\n") + found = find_fips_project_root() + if not found: + console.print( + "[red]✗[/red] Not in a project directory\n" + "[yellow]Hint:[/yellow] Run this command from within a project created by fips-agents" + ) + sys.exit(1) + _, template_info = found + project_type = get_project_type(template_info) + if not skip_confirmation: confirm = click.confirm("This will update multiple files. Continue?", default=True) if not confirm: console.print("[yellow]Cancelled[/yellow]") sys.exit(0) - categories = get_available_categories() + categories = get_available_categories(project_type) for category in categories: console.print(f"\n[bold]Processing category: {category}[/bold]") _patch_category(category, dry_run, skip_confirmation=skip_confirmation) @@ -174,23 +206,14 @@ def _patch_category(category: str, dry_run: bool, skip_confirmation: bool = Fals dry_run: If True, only show what would be changed skip_confirmation: If True, don't ask for confirmation """ - # Find project root - project_root = find_project_root() - if not project_root: + found = find_fips_project_root() + if not found: console.print( "[red]✗[/red] Not in a project directory\n" "[yellow]Hint:[/yellow] Run this command from within a project created by fips-agents" ) sys.exit(1) - - # Get template info - template_info = get_template_info(project_root) - if not template_info: - console.print( - "[red]✗[/red] No template metadata found\n" - "[yellow]Hint:[/yellow] This project may not have been created by fips-agents-cli" - ) - sys.exit(1) + project_root, template_info = found # Perform patch success, message = patch_category( diff --git a/src/fips_agents_cli/tools/patching.py b/src/fips_agents_cli/tools/patching.py index d91e215..7c6c8c6 100644 --- a/src/fips_agents_cli/tools/patching.py +++ b/src/fips_agents_cli/tools/patching.py @@ -15,8 +15,8 @@ console = Console() -# File categories for patching -FILE_CATEGORIES = { +# File categories for MCP server projects +MCP_FILE_CATEGORIES = { "generators": { "description": "Code generator templates (Jinja2)", "patterns": [ @@ -58,8 +58,8 @@ }, } -# Files to NEVER patch (user code) -NEVER_PATCH = [ +# Files to NEVER patch in MCP server projects (user code) +MCP_NEVER_PATCH = [ "src/tools/*.py", "src/resources/*.py", "src/prompts/*.py", @@ -73,6 +73,82 @@ "src/core/logging.py", # Custom logging ] +# File categories for agent and workflow projects (same template repo, +# same directory layout — both ship with chart/, docs/, build files, +# and .claude/commands/, none of which match the MCP layout) +AGENT_FILE_CATEGORIES = { + "chart": { + "description": "Helm chart templates", + "patterns": [ + "chart/templates/**/*", + "chart/Chart.yaml", + ], + "ask_before_patch": True, # User may have customized + }, + "docs": { + "description": "Documentation files", + "patterns": [ + "CLAUDE.md", + "AGENTS.md", + "docs/**/*", + ], + "ask_before_patch": False, # Usually safe to update + }, + "build": { + "description": "Build and deployment files", + "patterns": [ + "Makefile", + "Containerfile", + "deploy.sh", + "redeploy.sh", + ], + "ask_before_patch": True, # May be customized + }, + "claude": { + "description": "Claude Code slash commands shipped with the template", + "patterns": [ + ".claude/commands/**/*", + ], + "ask_before_patch": False, # Safe to overwrite + }, +} + +# Files to NEVER patch in agent / workflow projects (user code) +AGENT_NEVER_PATCH = [ + "src/agent.py", # User's agent implementation + "agent.yaml", # User's agent config + "chart/values.yaml", # User's deploy values + "src/fipsagents/**", # Vendored — managed by `fips-agents vendor --update` + "tests/**/*.py", + ".env*", + "README.md", + "pyproject.toml", # User may have added dependencies +] + + +def get_categories_for_type(project_type: str) -> tuple[dict, list[str]]: + """ + Return the (categories, never_patch) tuple for a given project type. + + Args: + project_type: One of 'mcp-server', 'agent', 'workflow'. Other types + (gateway, ui, sandbox) are not patchable yet and raise ValueError. + + Returns: + tuple: (file_categories_dict, never_patch_list) + + Raises: + ValueError: If the project type does not support patching. + """ + if project_type == "mcp-server": + return MCP_FILE_CATEGORIES, MCP_NEVER_PATCH + if project_type in ("agent", "workflow"): + return AGENT_FILE_CATEGORIES, AGENT_NEVER_PATCH + raise ValueError( + f"Patching is not supported for project type '{project_type}'. " + "Supported types: mcp-server, agent, workflow." + ) + def get_template_info(project_path: Path) -> dict[str, Any] | None: """ @@ -96,9 +172,58 @@ def get_template_info(project_path: Path) -> dict[str, Any] | None: return None -def get_available_categories() -> list[str]: - """Get list of available patch categories.""" - return list(FILE_CATEGORIES.keys()) +def get_project_type(template_info: dict[str, Any]) -> str: + """ + Read the project type from template-info, defaulting to 'mcp-server'. + + Projects scaffolded before .template-info gained the `template.type` + field (v0.8.x and earlier) are all MCP servers — that was the only + patchable type at the time. + """ + return template_info.get("template", {}).get("type", "mcp-server") + + +def get_available_categories(project_type: str = "mcp-server") -> list[str]: + """Get list of available patch categories for a given project type.""" + categories, _ = get_categories_for_type(project_type) + return list(categories.keys()) + + +def _clone_template_for_patch(template_info: dict[str, Any], temp_path: Path) -> Path: + """ + Clone the template repo and return the comparison root. + + For standalone repos (mcp-server, gateway, ui, sandbox), the comparison + root is the clone root itself. For monorepo subdirs (agent, workflow), + it's `temp_path / subdir`. + + Args: + template_info: Template metadata read from .template-info + temp_path: Pre-created temp directory the caller manages + + Returns: + Path: The directory whose layout mirrors the project — use this + as the root for glob/compare operations, not `temp_path`. + + Raises: + FileNotFoundError: If `template.subdir` is set but does not exist + in the cloned repo. + """ + template_block = template_info["template"] + template_url = template_block["url"] + subdir = template_block.get("subdir") + + clone_template(template_url, temp_path) + + if not subdir: + return temp_path + + template_root = temp_path / subdir + if not template_root.is_dir(): + raise FileNotFoundError( + f"Template subdir '{subdir}' not found in cloned repo {template_url}" + ) + return template_root def check_for_updates(project_path: Path, template_info: dict[str, Any]) -> dict[str, Any]: @@ -113,29 +238,27 @@ def check_for_updates(project_path: Path, template_info: dict[str, Any]) -> dict dict: Dictionary of categories with changed files """ template_url = template_info["template"]["url"] - # original_commit = template_info["template"]["full_commit"] # For future use + project_type = get_project_type(template_info) + file_categories, _ = get_categories_for_type(project_type) console.print(f"[cyan]Fetching latest template from {template_url}...[/cyan]") # Clone latest template to temp directory with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) - clone_template(template_url, temp_path) - - # Get latest commit - # (For now, we'll just compare files - can enhance to get actual latest commit) + template_root = _clone_template_for_patch(template_info, temp_path) updates = {} - for category, config in FILE_CATEGORIES.items(): + for category, config in file_categories.items(): changed_files = [] for pattern in config["patterns"]: # Find matching files in template - for template_file in temp_path.glob(pattern): + for template_file in template_root.glob(pattern): if template_file.is_file(): # Get relative path - rel_path = template_file.relative_to(temp_path) + rel_path = template_file.relative_to(template_root) project_file = project_path / rel_path # Check if file exists and is different @@ -174,35 +297,42 @@ def patch_category( Returns: tuple: (success, message) """ - if category not in FILE_CATEGORIES: - return False, f"Unknown category: {category}" + project_type = get_project_type(template_info) + file_categories, never_patch = get_categories_for_type(project_type) + + if category not in file_categories: + available = ", ".join(file_categories.keys()) or "(none)" + return ( + False, + f"Category '{category}' is not valid for {project_type} projects. " + f"Available: {available}", + ) - config = FILE_CATEGORIES[category] + config = file_categories[category] template_url = template_info["template"]["url"] console.print(f"\n[bold cyan]Patching Category: {category}[/bold cyan]") console.print(f"[dim]{config['description']}[/dim]\n") - # Clone template with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) console.print(f"[cyan]Fetching template from {template_url}...[/cyan]") - clone_template(template_url, temp_path) + template_root = _clone_template_for_patch(template_info, temp_path) console.print("[green]✓[/green] Template fetched\n") files_patched = 0 files_skipped = 0 for pattern in config["patterns"]: - for template_file in temp_path.glob(pattern): + for template_file in template_root.glob(pattern): if not template_file.is_file(): continue - rel_path = template_file.relative_to(temp_path) + rel_path = template_file.relative_to(template_root) project_file = project_path / rel_path # Check if file should be patched - if _should_never_patch(rel_path): + if _should_never_patch(rel_path, never_patch): console.print(f"[dim]Skipping (user code): {rel_path}[/dim]") files_skipped += 1 continue @@ -249,10 +379,10 @@ def _files_identical(file1: Path, file2: Path) -> bool: return False -def _should_never_patch(file_path: Path) -> bool: - """Check if a file should never be patched.""" +def _should_never_patch(file_path: Path, never_patch: list[str]) -> bool: + """Check if a file should never be patched, given the rule list.""" file_str = str(file_path) - for pattern in NEVER_PATCH: + for pattern in never_patch: if Path(file_str).match(pattern): return True return False diff --git a/src/fips_agents_cli/tools/project.py b/src/fips_agents_cli/tools/project.py index 5193771..03b88a1 100644 --- a/src/fips_agents_cli/tools/project.py +++ b/src/fips_agents_cli/tools/project.py @@ -520,6 +520,8 @@ def write_template_info( project_name: str, template_url: str, template_commit: str, + template_type: str, + template_subdir: str | None = None, github_repo: str | None = None, github_url: str | None = None, ) -> None: @@ -531,17 +533,27 @@ def write_template_info( project_name: Name of the generated project template_url: URL of the template repository template_commit: Git commit hash of the template + template_type: Project type ('mcp-server', 'agent', 'workflow', + 'gateway', 'ui', 'sandbox'). Read by `patch` to select the + right file categories and exclusions. + template_subdir: Subdirectory within the repo for monorepo templates + (e.g. 'templates/agent-loop'). Omit for standalone repos. github_repo: GitHub repository in "owner/name" format (optional) github_url: Full URL to the GitHub repository (optional) """ try: + template_block = { + "url": template_url, + "type": template_type, + "commit": template_commit[:12], # Short hash + "full_commit": template_commit, + } + if template_subdir: + template_block["subdir"] = template_subdir + template_info = { "generator": {"tool": "fips-agents-cli", "version": __version__}, - "template": { - "url": template_url, - "commit": template_commit[:12], # Short hash - "full_commit": template_commit, - }, + "template": template_block, "project": { "name": project_name, "created_at": datetime.now(timezone.utc).isoformat(), diff --git a/src/fips_agents_cli/tools/validation.py b/src/fips_agents_cli/tools/validation.py index 5c1591b..0ff6ab2 100644 --- a/src/fips_agents_cli/tools/validation.py +++ b/src/fips_agents_cli/tools/validation.py @@ -1,8 +1,10 @@ """Validation utilities for MCP component generation.""" +import json import keyword import re from pathlib import Path +from typing import Any import tomlkit from rich.console import Console @@ -12,9 +14,11 @@ def find_project_root() -> Path | None: """ - Find the project root by walking up from current directory. + Find the MCP server project root by walking up from current directory. - Looks for pyproject.toml with fastmcp dependency to identify MCP server projects. + Looks for pyproject.toml with fastmcp dependency. Use this for commands + that are MCP-specific (add, generate). For commands that work across + project types (like patch), use find_fips_project_root() instead. Returns: Path: Project root path if found @@ -51,6 +55,41 @@ def find_project_root() -> Path | None: return None +def find_fips_project_root() -> tuple[Path, dict[str, Any]] | None: + """ + Find any fips-agents-scaffolded project root by walking up from cwd. + + Looks for the .template-info file written by `fips-agents create`. Works + for every project type (mcp-server, agent, workflow, gateway, ui, sandbox). + + Backwards compat: projects scaffolded before .template-info gained the + `template.type` field will still be located, but the returned dict won't + include it. Callers should default a missing type to "mcp-server" — that + was the only patchable type at the time those projects were scaffolded. + + Returns: + tuple: (project_root, template_info_dict) if found + None: If no .template-info file is found in the current directory or + any of its parents + """ + current_path = Path.cwd() + + for parent in [current_path] + list(current_path.parents): + info_file = parent / ".template-info" + if not info_file.exists(): + continue + + try: + with open(info_file) as f: + template_info = json.load(f) + return parent, template_info + except Exception as e: + console.print(f"[yellow]⚠[/yellow] Could not parse {info_file}: {e}") + continue + + return None + + def is_valid_component_name(name: str) -> tuple[bool, str]: """ Validate component name as a valid Python identifier. diff --git a/tests/test_patch.py b/tests/test_patch.py new file mode 100644 index 0000000..bd7f786 --- /dev/null +++ b/tests/test_patch.py @@ -0,0 +1,295 @@ +"""Tests for the patch command and the patching tools layer.""" + +import json +from pathlib import Path + +import pytest + +from fips_agents_cli.cli import cli +from fips_agents_cli.tools import patching +from fips_agents_cli.tools.patching import ( + AGENT_FILE_CATEGORIES, + AGENT_NEVER_PATCH, + MCP_FILE_CATEGORIES, + MCP_NEVER_PATCH, + get_categories_for_type, + get_project_type, +) +from fips_agents_cli.tools.validation import find_fips_project_root + +# --------------------------------------------------------------------------- +# Unit tests — pure helpers +# --------------------------------------------------------------------------- + + +class TestGetProjectType: + def test_reads_type_from_template_info(self): + assert get_project_type({"template": {"type": "agent"}}) == "agent" + + def test_defaults_to_mcp_server_when_missing(self): + # Backwards compat: pre-#13 projects had no template.type + assert get_project_type({"template": {"url": "x"}}) == "mcp-server" + assert get_project_type({}) == "mcp-server" + + +class TestGetCategoriesForType: + def test_mcp_server(self): + cats, never = get_categories_for_type("mcp-server") + assert cats is MCP_FILE_CATEGORIES + assert never is MCP_NEVER_PATCH + + @pytest.mark.parametrize("project_type", ["agent", "workflow"]) + def test_agent_and_workflow_share_categories(self, project_type): + cats, never = get_categories_for_type(project_type) + assert cats is AGENT_FILE_CATEGORIES + assert never is AGENT_NEVER_PATCH + + @pytest.mark.parametrize("project_type", ["gateway", "ui", "sandbox", "bogus"]) + def test_unsupported_types_raise(self, project_type): + with pytest.raises(ValueError, match=project_type): + get_categories_for_type(project_type) + + def test_agent_categories_have_no_framework_language(self): + # Per project convention: avoid "framework" in user-facing strings + for category, config in AGENT_FILE_CATEGORIES.items(): + assert "framework" not in category.lower() + assert "framework" not in config["description"].lower() + + +# --------------------------------------------------------------------------- +# Unit tests — find_fips_project_root walks up to .template-info +# --------------------------------------------------------------------------- + + +class TestFindFipsProjectRoot: + def test_returns_none_when_no_template_info(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + assert find_fips_project_root() is None + + def test_finds_template_info_in_cwd(self, tmp_path, monkeypatch): + info = {"template": {"type": "agent"}} + (tmp_path / ".template-info").write_text(json.dumps(info)) + monkeypatch.chdir(tmp_path) + + result = find_fips_project_root() + assert result is not None + root, template_info = result + assert root == tmp_path + assert template_info == info + + def test_walks_up_to_parent(self, tmp_path, monkeypatch): + info = {"template": {"type": "mcp-server"}} + (tmp_path / ".template-info").write_text(json.dumps(info)) + nested = tmp_path / "src" / "tools" + nested.mkdir(parents=True) + monkeypatch.chdir(nested) + + result = find_fips_project_root() + assert result is not None + root, _ = result + assert root == tmp_path + + +# --------------------------------------------------------------------------- +# Unit tests — _clone_template_for_patch handles subdirs +# --------------------------------------------------------------------------- + + +class TestCloneTemplateForPatch: + def test_standalone_repo_returns_clone_root(self, tmp_path, monkeypatch): + def fake_clone(url, target_path, branch=None): + target_path.mkdir(parents=True, exist_ok=True) + (target_path / "Makefile").write_text("# fake\n") + return "abc123" + + monkeypatch.setattr(patching, "clone_template", fake_clone) + + info = {"template": {"url": "https://example.com/repo.git"}} + result = patching._clone_template_for_patch(info, tmp_path) + assert result == tmp_path + assert (result / "Makefile").exists() + + def test_monorepo_subdir_returns_subdir_root(self, tmp_path, monkeypatch): + def fake_clone(url, target_path, branch=None): + target_path.mkdir(parents=True, exist_ok=True) + sub = target_path / "templates" / "agent-loop" + sub.mkdir(parents=True) + (sub / "Makefile").write_text("# agent makefile\n") + (target_path / "README.md").write_text("# monorepo root\n") + return "abc123" + + monkeypatch.setattr(patching, "clone_template", fake_clone) + + info = { + "template": { + "url": "https://example.com/agent-template", + "subdir": "templates/agent-loop", + } + } + result = patching._clone_template_for_patch(info, tmp_path) + assert result == tmp_path / "templates" / "agent-loop" + assert (result / "Makefile").exists() + # Crucially, monorepo-root files are NOT visible to the patch comparator + assert not (result / "README.md").exists() + + def test_missing_subdir_raises(self, tmp_path, monkeypatch): + def fake_clone(url, target_path, branch=None): + target_path.mkdir(parents=True, exist_ok=True) + return "abc123" + + monkeypatch.setattr(patching, "clone_template", fake_clone) + + info = { + "template": { + "url": "https://example.com/agent-template", + "subdir": "templates/missing", + } + } + with pytest.raises(FileNotFoundError, match="templates/missing"): + patching._clone_template_for_patch(info, tmp_path) + + +# --------------------------------------------------------------------------- +# E2E test — `patch check` on a freshly-scaffolded agent project +# --------------------------------------------------------------------------- + + +def _make_fake_agent_template( + template_root: Path, makefile_body: str = "# template makefile\n" +) -> None: + """Build a minimal agent-loop template tree under template_root.""" + template_root.mkdir(parents=True, exist_ok=True) + (template_root / "Makefile").write_text(makefile_body) + (template_root / "Containerfile").write_text("FROM ubi9\n") + (template_root / "AGENTS.md").write_text("# template\n") + + chart = template_root / "chart" + (chart / "templates").mkdir(parents=True) + (chart / "Chart.yaml").write_text("name: agent-template\nversion: 0.1.0\n") + (chart / "values.yaml").write_text("image: agent-template\n") + (chart / "templates" / "deployment.yaml").write_text("# deploy\n") + + claude = template_root / ".claude" / "commands" + claude.mkdir(parents=True) + (claude / "plan-agent.md").write_text("# plan command\n") + + +def _make_fake_agent_project(project_root: Path) -> None: + """Build a minimal scaffolded agent project, simulating create-agent output.""" + project_root.mkdir(parents=True, exist_ok=True) + # Match the template baseline so most files are unchanged + _make_fake_agent_template(project_root) + # User-customized values (must never be patched) + (project_root / "chart" / "values.yaml").write_text("image: my-agent\n") + (project_root / "src").mkdir() + (project_root / "src" / "agent.py").write_text("# user code\n") + (project_root / "agent.yaml").write_text("model:\n name: my-model\n") + (project_root / "pyproject.toml").write_text('[project]\nname = "my-agent"\n') + + info = { + "generator": {"tool": "fips-agents-cli", "version": "0.0.0-test"}, + "template": { + "url": "https://github.com/fips-agents/agent-template", + "type": "agent", + "subdir": "templates/agent-loop", + "commit": "abcdef123456", + "full_commit": "abcdef1234567890", + }, + "project": {"name": "my-agent", "created_at": "2026-01-01T00:00:00+00:00"}, + } + (project_root / ".template-info").write_text(json.dumps(info, indent=2)) + + +class TestPatchAgentE2E: + """End-to-end: scaffolded agent project + `patch check` finds drift correctly.""" + + @pytest.fixture + def agent_project(self, tmp_path): + project = tmp_path / "my-agent" + _make_fake_agent_project(project) + return project + + def test_patch_check_reports_no_drift_when_template_unchanged( + self, agent_project, monkeypatch, cli_runner + ): + # Stub clone_template to drop the same content the project was scaffolded from + def fake_clone(url, target_path, branch=None): + sub = target_path / "templates" / "agent-loop" + _make_fake_agent_template(sub) + return "abcdef1234567890" + + monkeypatch.setattr(patching, "clone_template", fake_clone) + monkeypatch.chdir(agent_project) + + result = cli_runner.invoke(cli, ["patch", "check"]) + assert result.exit_code == 0, result.output + assert "up to date" in result.output + + def test_patch_check_reports_drift_in_agent_categories( + self, agent_project, monkeypatch, cli_runner + ): + # Template's Makefile has changed since scaffold time → "build" should appear + def fake_clone(url, target_path, branch=None): + sub = target_path / "templates" / "agent-loop" + _make_fake_agent_template(sub, makefile_body="# UPDATED template makefile\n") + return "newcommit12345" + + monkeypatch.setattr(patching, "clone_template", fake_clone) + monkeypatch.chdir(agent_project) + + result = cli_runner.invoke(cli, ["patch", "check"]) + assert result.exit_code == 0, result.output + assert "Available Updates" in result.output + assert "build" in result.output + # Crucially, MCP-only categories must NOT appear for an agent project + assert "generators" not in result.output + assert "core" not in result.output + + def test_patch_chart_applies_change_to_agent_project( + self, agent_project, monkeypatch, cli_runner + ): + # Template's chart/templates/deployment.yaml has changed + def fake_clone(url, target_path, branch=None): + sub = target_path / "templates" / "agent-loop" + _make_fake_agent_template(sub) + (sub / "chart" / "templates" / "deployment.yaml").write_text("# UPDATED deploy\n") + return "newcommit12345" + + monkeypatch.setattr(patching, "clone_template", fake_clone) + monkeypatch.chdir(agent_project) + + # `chart` has ask_before_patch=True, so use --skip-confirmation via `all`? + # Simpler: invoke patch chart with `input="y\n"` to accept the diff + result = cli_runner.invoke(cli, ["patch", "chart"], input="y\n") + assert result.exit_code == 0, result.output + + # values.yaml is in NEVER_PATCH — must NOT be touched + assert (agent_project / "chart" / "values.yaml").read_text() == "image: my-agent\n" + # User's agent.py is also off-limits + assert (agent_project / "src" / "agent.py").read_text() == "# user code\n" + # The drifted template file should now match the template + assert ( + agent_project / "chart" / "templates" / "deployment.yaml" + ).read_text() == "# UPDATED deploy\n" + + def test_mcp_only_subcommand_rejected_in_agent_project( + self, agent_project, monkeypatch, cli_runner + ): + # Running `patch generators` (MCP-only) inside an agent project must fail + # with a clear, type-aware error message — no clone should happen. + called = {"clone": False} + + def fake_clone(url, target_path, branch=None): + called["clone"] = True + return "x" + + monkeypatch.setattr(patching, "clone_template", fake_clone) + monkeypatch.chdir(agent_project) + + result = cli_runner.invoke(cli, ["patch", "generators"]) + assert result.exit_code == 1 + assert "agent" in result.output + assert "generators" in result.output + # Must enumerate the available agent categories + for cat in AGENT_FILE_CATEGORIES: + assert cat in result.output