diff --git a/src/apm_cli/bundle/lockfile_enrichment.py b/src/apm_cli/bundle/lockfile_enrichment.py index 5be6e8d598..868bcab035 100644 --- a/src/apm_cli/bundle/lockfile_enrichment.py +++ b/src/apm_cli/bundle/lockfile_enrichment.py @@ -58,6 +58,10 @@ ".github/skills/": ".agents/skills/", ".github/agents/": ".opencode/agents/", }, + "openfox": { + ".agents/skills/": ".openfox/skills/", + ".github/skills/": ".openfox/skills/", + }, "vscode": { ".claude/skills/": ".github/skills/", ".claude/agents/": ".github/agents/", diff --git a/src/apm_cli/commands/init.py b/src/apm_cli/commands/init.py index 49ded99918..43fdcb58fb 100644 --- a/src/apm_cli/commands/init.py +++ b/src/apm_cli/commands/init.py @@ -63,6 +63,7 @@ def _detect_agentrc(project_root: Path) -> tuple[bool, bool]: "claude", "cursor", "opencode", + "openfox", "codex", "gemini", "windsurf", diff --git a/src/apm_cli/compilation/context_optimizer.py b/src/apm_cli/compilation/context_optimizer.py index 3a08b0a70e..3e962b91fb 100644 --- a/src/apm_cli/compilation/context_optimizer.py +++ b/src/apm_cli/compilation/context_optimizer.py @@ -63,6 +63,7 @@ ".github", ".kiro", ".opencode", + ".openfox", ".windsurf", } ) diff --git a/src/apm_cli/core/deployment_ledger.py b/src/apm_cli/core/deployment_ledger.py index 370c2f4e86..7b60ecf38b 100644 --- a/src/apm_cli/core/deployment_ledger.py +++ b/src/apm_cli/core/deployment_ledger.py @@ -42,6 +42,7 @@ ".gemini/": "gemini", ".codex/": "codex", ".opencode/": "opencode", + ".openfox/": "openfox", } _LEGACY_USER_TARGET_PREFIXES = { ".copilot/": "copilot", diff --git a/src/apm_cli/core/errors.py b/src/apm_cli/core/errors.py index d2b2ebf7cc..2e568458fb 100644 --- a/src/apm_cli/core/errors.py +++ b/src/apm_cli/core/errors.py @@ -64,7 +64,7 @@ class EmptyTargetsListError(TargetResolutionError): ".github/copilot-instructions.md, .github/instructions/, " ".github/agents/, .github/prompts/, .github/hooks/, " ".codex/, .gemini/, GEMINI.md, " - ".opencode/, .windsurf/, .kiro/" + ".opencode/, .openfox/, .windsurf/, .kiro/" ) diff --git a/src/apm_cli/core/target_catalog.py b/src/apm_cli/core/target_catalog.py index 7c56bb51ea..e8098377f8 100644 --- a/src/apm_cli/core/target_catalog.py +++ b/src/apm_cli/core/target_catalog.py @@ -229,6 +229,13 @@ def _build_target_catalog( primitive_profile="opencode", compile_family="agents", ), + _capability( + "openfox", + "OpenFox native .openfox configuration", + in_all=True, + primitive_profile="openfox", + compile_family="agents", + ), _capability( "windsurf", "Windsurf native .windsurf and .agents configuration", diff --git a/src/apm_cli/core/target_detection.py b/src/apm_cli/core/target_detection.py index d7a7ccb0a6..be16f5470f 100644 --- a/src/apm_cli/core/target_detection.py +++ b/src/apm_cli/core/target_detection.py @@ -74,6 +74,7 @@ def agents_alias_was_detected() -> bool: "hermes", "kiro", "opencode", + "openfox", "vscode", "windsurf", "all", @@ -118,6 +119,7 @@ def agents_alias_was_detected() -> bool: "hermes", "kiro", "opencode", + "openfox", "vscode", "windsurf", "all", @@ -125,7 +127,7 @@ def agents_alias_was_detected() -> bool: ] -def detect_target( # noqa: PLR0911 +def detect_target( # noqa: C901, PLR0911 project_root: Path, explicit_target: str | None = None, config_target: str | None = None, @@ -155,6 +157,8 @@ def detect_target( # noqa: PLR0911 return "cursor", "explicit --target flag" elif explicit_target == "opencode": return "opencode", "explicit --target flag" + elif explicit_target == "openfox": + return "openfox", "explicit --target flag" elif explicit_target == "codex": return "codex", "explicit --target flag" elif explicit_target == "gemini": @@ -186,6 +190,8 @@ def detect_target( # noqa: PLR0911 return "cursor", "apm.yml target" elif config_target == "opencode": return "opencode", "apm.yml target" + elif config_target == "openfox": + return "openfox", "apm.yml target" elif config_target == "codex": return "codex", "apm.yml target" elif config_target == "gemini": @@ -212,6 +218,7 @@ def detect_target( # noqa: PLR0911 claude_exists = (project_root / ".claude").exists() cursor_exists = (project_root / ".cursor").is_dir() opencode_exists = (project_root / ".opencode").is_dir() + openfox_exists = (project_root / ".openfox").is_dir() codex_exists = (project_root / ".codex").is_dir() gemini_exists = (project_root / ".gemini").is_dir() windsurf_exists = (project_root / ".windsurf").is_dir() @@ -226,6 +233,8 @@ def detect_target( # noqa: PLR0911 detected.append(".cursor/") if opencode_exists: detected.append(".opencode/") + if openfox_exists: + detected.append(".openfox/") if codex_exists: detected.append(".codex/") if gemini_exists: @@ -247,6 +256,8 @@ def detect_target( # noqa: PLR0911 return "cursor", "detected .cursor/ folder" elif opencode_exists: return "opencode", "detected .opencode/ folder" + elif openfox_exists: + return "openfox", "detected .openfox/ folder" elif codex_exists: return "codex", "detected .codex/ folder" elif gemini_exists: @@ -281,6 +292,7 @@ def should_compile_agents_md(target: CompileTargetType) -> bool: "vscode", "cursor", "opencode", + "openfox", "codex", "gemini", "antigravity", @@ -427,9 +439,10 @@ def get_target_description(target: UserTargetType) -> str: "kiro": "AGENTS.md + .kiro/steering/ + .kiro/skills/ + .kiro/hooks/ + .kiro/settings/mcp.json", "openclaw": ".agents/skills/ (project) or ~/.openclaw/skills/ (--global) -- experimental", "opencode": "AGENTS.md + .opencode/agents/ + .opencode/commands/ + .opencode/skills/", + "openfox": "AGENTS.md + .openfox/agents/ + .openfox/skills/", "vscode": "AGENTS.md + .github/copilot-instructions.md + .github/prompts/ + .github/agents/", "windsurf": "AGENTS.md + .windsurf/rules/ + .agents/skills/ + .windsurf/workflows/ + .windsurf/hooks.json", - "all": "AGENTS.md + CLAUDE.md + GEMINI.md + .github/copilot-instructions.md + .github/ + .claude/ + .cursor/ + .opencode/ + .codex/ + .gemini/ + .windsurf/ + .kiro/ + .agents/", + "all": "AGENTS.md + CLAUDE.md + GEMINI.md + .github/copilot-instructions.md + .github/ + .claude/ + .cursor/ + .opencode/ + .openfox/ + .codex/ + .gemini/ + .windsurf/ + .kiro/ + .agents/", "minimal": "AGENTS.md only (create .github/, .claude/, or .gemini/ for full integration)", } return descriptions.get(normalized, "unknown target") @@ -1064,6 +1077,7 @@ def resolve_manifest_target_decision( ("gemini", "file", "GEMINI.md"), ("grok-build", "dir", ".grok"), ("opencode", "dir", ".opencode"), + ("openfox", "dir", ".openfox"), ("windsurf", "dir", ".windsurf"), ("kiro", "dir", ".kiro"), ] @@ -1077,6 +1091,7 @@ def resolve_manifest_target_decision( "gemini", "grok-build", "opencode", + "openfox", "windsurf", "kiro", ] @@ -1090,6 +1105,7 @@ def resolve_manifest_target_decision( "gemini": ".gemini/", "grok-build": ".grok/", "opencode": ".opencode/", + "openfox": ".openfox/", "windsurf": ".windsurf/", "kiro": ".kiro/", } @@ -1104,6 +1120,7 @@ def resolve_manifest_target_decision( "gemini": "GEMINI.md", "grok-build": ".grok/", "opencode": ".opencode/", + "openfox": ".openfox/", "windsurf": ".windsurf/", "kiro": ".kiro/", } diff --git a/src/apm_cli/integration/agent_integrator.py b/src/apm_cli/integration/agent_integrator.py index 50f69808a4..b3cbc7d6aa 100644 --- a/src/apm_cli/integration/agent_integrator.py +++ b/src/apm_cli/integration/agent_integrator.py @@ -9,8 +9,9 @@ import re from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any +import frontmatter import yaml from apm_cli.install.primitive_classification import ( @@ -25,7 +26,7 @@ from apm_cli.utils.diagnostics import printable_ascii_text from apm_cli.utils.path_security import PathTraversalError, ensure_path_within from apm_cli.utils.paths import portable_relpath -from apm_cli.utils.yaml_io import load_yaml_str, yaml_to_str +from apm_cli.utils.yaml_io import load_yaml_str, loads_frontmatter, yaml_to_str if TYPE_CHECKING: from apm_cli.integration.targets import TargetProfile @@ -262,8 +263,27 @@ def integrate_agents_for_target( rel_path = portable_relpath(target_path, project_root) + rendered_openfox_content: str | None = None + openfox_links_resolved = 0 + expected_content: str | None = None + if mapping.format_id == "openfox_agent": + rendered_openfox_content, openfox_links_resolved = self.render_openfox_agent( + source_file, + target_path, + diagnostics=diagnostics, + package_name=package_info.package.name, + ) + expected_content = rendered_openfox_content + skip, adopted = self._check_adopt_or_skip( - target_path, source_file, rel_path, managed_files, force, diagnostics, target_paths + target_path, + source_file, + rel_path, + managed_files, + force, + diagnostics, + target_paths, + expected_content=expected_content, ) if skip: if adopted: @@ -280,6 +300,10 @@ def integrate_agents_for_target( package_name=package_info.package.name, ) links_resolved = 0 + elif mapping.format_id == "openfox_agent": + if rendered_openfox_content is not None: + write_text_lf(target_path, rendered_openfox_content) + links_resolved = openfox_links_resolved else: if mapping.format_id == "opencode_agent": self._warn_opencode_frontmatter( @@ -363,6 +387,110 @@ def copy_agent(self, source: Path, target: Path) -> int: write_text_lf(target, content) return links_resolved + @staticmethod + def _normalize_openfox_tools( + tools: Any, + source_name: str, + diagnostics: DiagnosticCollector | None = None, + package_name: str | None = None, + ) -> list[str] | None: + """Convert tools (list, comma-separated str, or bool mapping) to allowedTools list.""" + pkg_tag = f"{package_name}/" if package_name else "" + if tools is None: + return None + if isinstance(tools, list): + return [str(t).strip() for t in tools if str(t).strip()] + if isinstance(tools, str): + return [t.strip() for t in tools.split(",") if t.strip()] + if isinstance(tools, dict): + allowed: list[str] = [] + for k, v in tools.items(): + if isinstance(v, bool): + if v: + allowed.append(str(k).strip()) + elif diagnostics is not None: + diagnostics.warn( + message=( + f"OpenFox agent {pkg_tag}{printable_ascii_text(source_name)}: " + f"mapping tool '{printable_ascii_text(str(k))}' has non-boolean value ({type(v).__name__}); " + "omitted from allowedTools." + ), + package=printable_ascii_text(package_name or ""), + ) + return allowed + if diagnostics is not None: + diagnostics.warn( + message=( + f"OpenFox agent {pkg_tag}{printable_ascii_text(source_name)}: " + f"unsupported 'tools' format ({type(tools).__name__}); could not convert to allowedTools." + ), + package=printable_ascii_text(package_name or ""), + ) + return None + + def render_openfox_agent( + self, + source: Path, + target: Path, + diagnostics: DiagnosticCollector | None = None, + package_name: str | None = None, + ) -> tuple[str, int]: + """Render OpenFox agent content ensuring required id and allowedTools in frontmatter.""" + if source.is_symlink(): + raise ValueError(f"Refusing to read symlink source: {source}") + content = source.read_text(encoding="utf-8") + content, links_resolved = self.resolve_links(content, source, target) + + stem = source.name[:-9] if source.name.endswith(".agent.md") else source.stem + try: + post = loads_frontmatter(content) + metadata = getattr(post, "metadata", None) + if not isinstance(metadata, dict): + metadata = {} + if not metadata.get("id"): + metadata["id"] = metadata.get("name") or stem + if "tools" in metadata and "allowedTools" not in metadata: + converted = self._normalize_openfox_tools( + metadata["tools"], + source.name, + diagnostics=diagnostics, + package_name=package_name, + ) + if converted is not None: + metadata["allowedTools"] = converted + post.metadata = metadata + content = frontmatter.dumps(post, sort_keys=False) + except Exception as exc: + if diagnostics is not None: + pkg_tag = f"{package_name}/" if package_name else "" + diagnostics.warn( + message=( + f"OpenFox agent {pkg_tag}{printable_ascii_text(source.name)}: " + f"failed to parse frontmatter ({exc}); agent written without metadata normalization." + ), + package=printable_ascii_text(package_name or ""), + ) + if not content.endswith("\n"): + content += "\n" + return content, links_resolved + + def _write_openfox_agent( + self, + source: Path, + target: Path, + diagnostics: DiagnosticCollector | None = None, + package_name: str | None = None, + ) -> int: + """Write an OpenFox agent file ensuring required id in frontmatter.""" + content, links_resolved = self.render_openfox_agent( + source, + target, + diagnostics=diagnostics, + package_name=package_name, + ) + write_text_lf(target, content) + return links_resolved + # ------------------------------------------------------------------ # OpenCode validate-and-warn (Phase 1 of #581) # ------------------------------------------------------------------ diff --git a/src/apm_cli/integration/targets.py b/src/apm_cli/integration/targets.py index 1c4fa98d18..ade42c4ea0 100644 --- a/src/apm_cli/integration/targets.py +++ b/src/apm_cli/integration/targets.py @@ -996,6 +996,23 @@ def _encode_copilot_app_locator(path: Path) -> str: }, include_scoped_in_user_root_context=True, ), + # OpenFox -- project-scope only (.openfox/skills/ and .openfox/agents/). + # No MCP support. + "openfox": TargetProfile( + capability=TARGET_CAPABILITIES["openfox"], + root_dir=".openfox", + primitives={ + "agents": PrimitiveMapping("agents", ".agent.md", "openfox_agent"), + "skills": PrimitiveMapping( + "skills", + "/SKILL.md", + "skill_standard", + ), + }, + auto_create=True, + detect_by_dir=True, + user_supported=False, + ), # Windsurf/Cascade (now Devin Desktop) -- .windsurf/ is the workspace # config directory. # Rules are markdown files with trigger/globs frontmatter under .windsurf/rules/. diff --git a/src/apm_cli/models/dependency/host_virtual.py b/src/apm_cli/models/dependency/host_virtual.py index 1577488859..4f5ce8a590 100644 --- a/src/apm_cli/models/dependency/host_virtual.py +++ b/src/apm_cli/models/dependency/host_virtual.py @@ -29,6 +29,7 @@ ".gemini", ".github", ".kiro", + ".openfox", ".windsurf", } ) diff --git a/src/apm_cli/policy/policy_checks.py b/src/apm_cli/policy/policy_checks.py index b303a6cf2f..8afe3a47bc 100644 --- a/src/apm_cli/policy/policy_checks.py +++ b/src/apm_cli/policy/policy_checks.py @@ -777,6 +777,7 @@ def _check_scripts_policy( ".cursor/rules", ".claude", ".opencode", + ".openfox", ".kiro", ] diff --git a/tests/unit/core/test_scope.py b/tests/unit/core/test_scope.py index d52e2a304c..95b17a0f7b 100644 --- a/tests/unit/core/test_scope.py +++ b/tests/unit/core/test_scope.py @@ -180,6 +180,7 @@ def test_all_known_targets_present(self): "copilot-app", "agent-skills", "openclaw", + "openfox", "hermes", "grok-bot", } @@ -211,6 +212,10 @@ def test_kiro_is_supported_at_user_scope(self): assert KNOWN_TARGETS["kiro"].user_supported is True assert KNOWN_TARGETS["kiro"].user_root_dir == ".kiro" + def test_openfox_is_not_supported_at_user_scope(self): + assert KNOWN_TARGETS["openfox"].user_supported is False + assert KNOWN_TARGETS["openfox"].for_scope(user_scope=True) is None + def test_copilot_user_root_dir(self): assert KNOWN_TARGETS["copilot"].user_root_dir == ".copilot" diff --git a/tests/unit/core/test_target_catalog.py b/tests/unit/core/test_target_catalog.py index 872834f312..cb6ee9886a 100644 --- a/tests/unit/core/test_target_catalog.py +++ b/tests/unit/core/test_target_catalog.py @@ -46,6 +46,7 @@ def test_current_target_sets_and_aliases_are_characterized() -> None: "grok-build", "kiro", "opencode", + "openfox", "vscode", "windsurf", } @@ -90,6 +91,7 @@ def test_current_target_sets_and_aliases_are_characterized() -> None: "kiro", "openclaw", "opencode", + "openfox", "vscode", "windsurf", } @@ -270,6 +272,15 @@ def test_current_native_profiles_are_characterized() -> None: "agents", None, ), + "openfox": ( + ".openfox", + { + "agents": ("agents", ".agent.md", "openfox_agent", None, False), + "skills": ("skills", "/SKILL.md", "skill_standard", None, False), + }, + "agents", + None, + ), "windsurf": ( ".windsurf", { diff --git a/tests/unit/core/test_target_catalog_owner_invariant.py b/tests/unit/core/test_target_catalog_owner_invariant.py index 8c779d5a35..644abddd9a 100644 --- a/tests/unit/core/test_target_catalog_owner_invariant.py +++ b/tests/unit/core/test_target_catalog_owner_invariant.py @@ -24,6 +24,7 @@ "kiro", "openclaw", "opencode", + "openfox", "vscode", "windsurf", } diff --git a/tests/unit/core/test_target_detection.py b/tests/unit/core/test_target_detection.py index 53d5b9cb38..9e5e143521 100644 --- a/tests/unit/core/test_target_detection.py +++ b/tests/unit/core/test_target_detection.py @@ -481,6 +481,66 @@ def test_opencode_no_compile_claude_md(self): assert should_compile_claude_md("opencode") is False +class TestDetectTargetOpenfox: + """Tests for auto-detection and explicit openfox target.""" + + def test_explicit_target_openfox(self, tmp_path): + """Explicit --target openfox always wins.""" + target, reason = detect_target( + project_root=tmp_path, + explicit_target="openfox", + ) + assert target == "openfox" + assert reason == "explicit --target flag" + + def test_config_target_openfox(self, tmp_path): + """Config target openfox is used when no explicit target.""" + target, reason = detect_target( + project_root=tmp_path, + explicit_target=None, + config_target="openfox", + ) + assert target == "openfox" + assert reason == "apm.yml target" + + def test_auto_detect_openfox_only(self, tmp_path): + """Auto-detect openfox when only .openfox/ exists.""" + (tmp_path / ".openfox").mkdir() + target, reason = detect_target( + project_root=tmp_path, + explicit_target=None, + config_target=None, + ) + assert target == "openfox" + assert ".openfox/" in reason + + def test_auto_detect_openfox_plus_github(self, tmp_path): + """Auto-detect all when .openfox/ and .github/ exist.""" + (tmp_path / ".github").mkdir() + (tmp_path / ".openfox").mkdir() + target, _ = detect_target( + project_root=tmp_path, + explicit_target=None, + config_target=None, + ) + assert target == "all" + + def test_openfox_compile_agents_md(self): + """OpenFox target should compile AGENTS.md.""" + assert should_compile_agents_md("openfox") is True + + def test_openfox_no_compile_claude_md(self): + """OpenFox target should NOT compile CLAUDE.md.""" + assert should_compile_claude_md("openfox") is False + + def test_openfox_description(self): + """Description for openfox target.""" + desc = get_target_description("openfox") + assert "AGENTS.md" in desc + assert ".openfox/agents/" in desc + assert ".openfox/skills/" in desc + + class TestDetectTargetWindsurf: """Tests for auto-detection and explicit windsurf target.""" @@ -551,6 +611,14 @@ def test_windsurf_in_valid_target_values(self): """Windsurf must be accepted by the --target parser.""" assert "windsurf" in VALID_TARGET_VALUES + def test_openfox_in_all_canonical_targets(self): + """OpenFox must appear in ALL_CANONICAL_TARGETS.""" + assert "openfox" in ALL_CANONICAL_TARGETS + + def test_openfox_in_valid_target_values(self): + """OpenFox must be accepted by the --target parser.""" + assert "openfox" in VALID_TARGET_VALUES + # --------------------------------------------------------------------------- # TargetParamType tests diff --git a/tests/unit/integration/test_agent_integrator.py b/tests/unit/integration/test_agent_integrator.py index 2fe2ce59d3..21fee440a9 100644 --- a/tests/unit/integration/test_agent_integrator.py +++ b/tests/unit/integration/test_agent_integrator.py @@ -1119,7 +1119,7 @@ def _verbatim_copy_agent_targets() -> list[str]: name for name, profile in KNOWN_TARGETS.items() if (mapping := profile.primitives.get("agents")) is not None - and mapping.format_id not in {"codex_agent", "kiro_agent"} + and mapping.format_id not in {"codex_agent", "kiro_agent", "openfox_agent"} ) diff --git a/tests/unit/integration/test_data_driven_dispatch.py b/tests/unit/integration/test_data_driven_dispatch.py index 4ec619a18b..dcd1aae181 100644 --- a/tests/unit/integration/test_data_driven_dispatch.py +++ b/tests/unit/integration/test_data_driven_dispatch.py @@ -316,6 +316,7 @@ def test_partition_parity_with_old_buckets(self): "agents_cursor", "agents_kiro", "agents_opencode", + "agents_openfox", "agents_codex", # NOTE: windsurf no longer exposes an 'agents' primitive # (its content deploys as skills under .agents/skills/). diff --git a/tests/unit/integration/test_openfox_target.py b/tests/unit/integration/test_openfox_target.py new file mode 100644 index 0000000000..ed1dcab302 --- /dev/null +++ b/tests/unit/integration/test_openfox_target.py @@ -0,0 +1,396 @@ +"""Unit and integration tests for the OpenFox target.""" + +from __future__ import annotations + +from datetime import datetime +from pathlib import Path + +from apm_cli.core.target_catalog import TARGET_CAPABILITIES, expand_all +from apm_cli.core.target_detection import ( + ALL_CANONICAL_TARGETS, + VALID_TARGET_VALUES, + detect_target, + get_target_description, + should_compile_agents_md, + should_compile_claude_md, +) +from apm_cli.integration.agent_integrator import AgentIntegrator +from apm_cli.integration.skill_integrator import SkillIntegrator +from apm_cli.integration.targets import KNOWN_TARGETS +from apm_cli.models.apm_package import ( + APMPackage, + GitReferenceType, + PackageInfo, + ResolvedReference, +) +from apm_cli.utils.yaml_io import loads_frontmatter + + +def _make_package_info(package_dir: Path, name: str = "test-pkg") -> PackageInfo: + package = APMPackage( + name=name, + version="1.0.0", + package_path=package_dir, + source=f"github.com/test/{name}", + ) + resolved_ref = ResolvedReference( + original_ref="main", + ref_type=GitReferenceType.BRANCH, + resolved_commit="abc123", + ref_name="main", + ) + return PackageInfo( + package=package, + install_path=package_dir, + resolved_reference=resolved_ref, + installed_at=datetime.now().isoformat(), + ) + + +class TestOpenFoxTargetProfile: + """Tests for the OpenFox target profile definition.""" + + def test_target_capabilities(self) -> None: + cap = TARGET_CAPABILITIES["openfox"] + assert cap.name == "openfox" + assert cap.in_all is True + assert cap.compile_family == "agents" + assert cap.primitive_profile == "openfox" + assert cap.mcp_only is False + + def test_known_targets_profile(self) -> None: + profile = KNOWN_TARGETS["openfox"] + assert profile.root_dir == ".openfox" + assert profile.auto_create is True + assert profile.detect_by_dir is True + assert profile.user_supported is False + assert profile.user_root_dir is None + assert set(profile.primitives.keys()) == {"skills", "agents"} + + def test_skills_mapping(self) -> None: + mapping = KNOWN_TARGETS["openfox"].primitives["skills"] + assert mapping.subdir == "skills" + assert mapping.extension == "/SKILL.md" + assert mapping.format_id == "skill_standard" + assert mapping.deploy_root is None + assert KNOWN_TARGETS["openfox"].skills_rel_root == ".openfox/skills" + + def test_agents_mapping(self) -> None: + mapping = KNOWN_TARGETS["openfox"].primitives["agents"] + assert mapping.subdir == "agents" + assert mapping.extension == ".agent.md" + assert mapping.format_id == "openfox_agent" + + def test_membership_in_target_sets(self) -> None: + assert "openfox" in ALL_CANONICAL_TARGETS + assert "openfox" in VALID_TARGET_VALUES + assert "openfox" in expand_all("install") + + def test_no_global_user_scope(self) -> None: + profile = KNOWN_TARGETS["openfox"] + assert profile.user_supported is False + assert profile.for_scope(user_scope=True) is None + + +class TestOpenFoxTargetDetection: + """Tests for target detection with OpenFox.""" + + def test_detect_by_dir(self, tmp_path: Path) -> None: + (tmp_path / ".openfox").mkdir() + target, reason = detect_target(tmp_path) + assert target == "openfox" + assert ".openfox/" in reason + + def test_explicit_target_wins(self, tmp_path: Path) -> None: + target, reason = detect_target(tmp_path, explicit_target="openfox") + assert target == "openfox" + assert reason == "explicit --target flag" + + def test_config_target(self, tmp_path: Path) -> None: + target, reason = detect_target(tmp_path, config_target="openfox") + assert target == "openfox" + assert reason == "apm.yml target" + + def test_compilation_routing(self) -> None: + assert should_compile_agents_md("openfox") is True + assert should_compile_claude_md("openfox") is False + + def test_description(self) -> None: + desc = get_target_description("openfox") + assert "AGENTS.md" in desc + assert ".openfox/agents/" in desc + assert ".openfox/skills/" in desc + + +class TestOpenFoxSkillIntegration: + """Tests for deploying skills to .openfox/skills/.""" + + def test_skill_deployment_path(self, tmp_path: Path) -> None: + (tmp_path / ".openfox").mkdir() + pkg_dir = tmp_path / "pkg" + pkg_dir.mkdir() + skill_dir = pkg_dir / ".apm" / "skills" / "my-skill" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + "---\nname: my-skill\ndescription: Test skill\n---\n# My Skill\n", + encoding="utf-8", + ) + + integrator = SkillIntegrator() + pkg_info = _make_package_info(pkg_dir) + integrator.integrate_package_skill( + pkg_info, + tmp_path, + targets=[KNOWN_TARGETS["openfox"]], + ) + + deployed = tmp_path / ".openfox" / "skills" / "my-skill" / "SKILL.md" + assert deployed.exists() + assert "Test skill" in deployed.read_text(encoding="utf-8") + + +class TestOpenFoxAgentIntegration: + """Tests for deploying agents to .openfox/agents/.""" + + def test_agent_deployment_with_missing_id_injects_id(self, tmp_path: Path) -> None: + (tmp_path / ".openfox").mkdir() + pkg_dir = tmp_path / "pkg" + pkg_dir.mkdir() + agent_dir = pkg_dir / ".apm" / "agents" + agent_dir.mkdir(parents=True) + (agent_dir / "reviewer.agent.md").write_text( + "---\nname: reviewer\ndescription: Code reviewer\ntools:\n - bash\n - read_file\n---\nReview code.\n", + encoding="utf-8", + ) + + integrator = AgentIntegrator() + pkg_info = _make_package_info(pkg_dir) + result = integrator.integrate_agents_for_target( + KNOWN_TARGETS["openfox"], + pkg_info, + tmp_path, + ) + assert result.files_integrated == 1 + + deployed = tmp_path / ".openfox" / "agents" / "reviewer.agent.md" + assert deployed.exists() + post = loads_frontmatter(deployed.read_text(encoding="utf-8")) + assert post.metadata["id"] == "reviewer" + assert post.metadata["name"] == "reviewer" + assert post.metadata["allowedTools"] == ["bash", "read_file"] + assert "Review code." in post.content + + def test_agent_deployment_preserves_existing_id(self, tmp_path: Path) -> None: + (tmp_path / ".openfox").mkdir() + pkg_dir = tmp_path / "pkg" + pkg_dir.mkdir() + agent_dir = pkg_dir / ".apm" / "agents" + agent_dir.mkdir(parents=True) + (agent_dir / "custom.agent.md").write_text( + "---\nid: my-special-id\nname: Custom Agent\ndescription: Specialized\n---\nPrompt body.\n", + encoding="utf-8", + ) + + integrator = AgentIntegrator() + pkg_info = _make_package_info(pkg_dir) + integrator.integrate_agents_for_target( + KNOWN_TARGETS["openfox"], + pkg_info, + tmp_path, + ) + + deployed = tmp_path / ".openfox" / "agents" / "custom.agent.md" + assert deployed.exists() + post = loads_frontmatter(deployed.read_text(encoding="utf-8")) + assert post.metadata["id"] == "my-special-id" + assert post.metadata["name"] == "Custom Agent" + + def test_agent_plain_md_extension_becomes_agent_md(self, tmp_path: Path) -> None: + (tmp_path / ".openfox").mkdir() + pkg_dir = tmp_path / "pkg" + pkg_dir.mkdir() + agent_dir = pkg_dir / ".apm" / "agents" + agent_dir.mkdir(parents=True) + (agent_dir / "helper.md").write_text( + "---\ndescription: General helper\n---\nHelp the user.\n", + encoding="utf-8", + ) + + integrator = AgentIntegrator() + pkg_info = _make_package_info(pkg_dir) + integrator.integrate_agents_for_target( + KNOWN_TARGETS["openfox"], + pkg_info, + tmp_path, + ) + + deployed = tmp_path / ".openfox" / "agents" / "helper.agent.md" + assert deployed.exists() + raw = deployed.read_text(encoding="utf-8") + assert raw.endswith("\n") + post = loads_frontmatter(raw) + assert post.metadata["id"] == "helper" + + def test_agent_frontmatter_preserves_order_and_warns_on_malformed(self, tmp_path: Path) -> None: + (tmp_path / ".openfox").mkdir() + pkg_dir = tmp_path / "pkg" + pkg_dir.mkdir() + agent_dir = pkg_dir / ".apm" / "agents" + agent_dir.mkdir(parents=True) + # Malformed frontmatter + (agent_dir / "bad.agent.md").write_text( + "---\n: bad yaml\n---\nBroken frontmatter.\n", + encoding="utf-8", + ) + + from apm_cli.utils.diagnostics import DiagnosticCollector + + diagnostics = DiagnosticCollector() + integrator = AgentIntegrator() + pkg_info = _make_package_info(pkg_dir) + integrator.integrate_agents_for_target( + KNOWN_TARGETS["openfox"], + pkg_info, + tmp_path, + diagnostics=diagnostics, + ) + + deployed = tmp_path / ".openfox" / "agents" / "bad.agent.md" + assert deployed.exists() + assert deployed.read_text(encoding="utf-8").endswith("\n") + # Check that warning diagnostic was emitted + warnings = diagnostics.by_category().get("warning", []) + assert any("OpenFox agent" in w.message and "bad.agent.md" in w.message for w in warnings) + + def test_hidden_trees_and_virtual_roots_include_openfox(self) -> None: + from apm_cli.compilation.context_optimizer import PLACEMENT_HIDDEN_TOOL_TREES + from apm_cli.models.dependency.host_virtual import _HOST_QUALIFIED_VIRTUAL_ROOTS + + assert ".openfox" in PLACEMENT_HIDDEN_TOOL_TREES + assert ".openfox" in _HOST_QUALIFIED_VIRTUAL_ROOTS + + def test_openfox_bundle_cross_target_maps(self) -> None: + from apm_cli.bundle.lockfile_enrichment import _CROSS_TARGET_MAPS + + mapping = _CROSS_TARGET_MAPS["openfox"] + assert mapping[".agents/skills/"] == ".openfox/skills/" + assert mapping[".github/skills/"] == ".openfox/skills/" + assert ".github/agents/" not in mapping + + def test_openfox_tools_normalization_string_and_dict(self, tmp_path: Path) -> None: + from apm_cli.utils.diagnostics import DiagnosticCollector + + (tmp_path / ".openfox").mkdir() + pkg_dir = tmp_path / "pkg" + pkg_dir.mkdir() + agent_dir = pkg_dir / ".apm" / "agents" + agent_dir.mkdir(parents=True) + + # Agent with comma-separated tools string + (agent_dir / "str_tools.agent.md").write_text( + "---\ntools: 'read, write, bash'\n---\nPrompt.\n", + encoding="utf-8", + ) + # Agent with boolean dictionary tools + (agent_dir / "dict_tools.agent.md").write_text( + "---\ntools:\n read: true\n write: false\n bash: true\n invalid: 42\n---\nPrompt.\n", + encoding="utf-8", + ) + # Agent with unsupported tools type + (agent_dir / "bad_tools.agent.md").write_text( + "---\ntools: 12345\n---\nPrompt.\n", + encoding="utf-8", + ) + + diagnostics = DiagnosticCollector() + integrator = AgentIntegrator() + pkg_info = _make_package_info(pkg_dir) + integrator.integrate_agents_for_target( + KNOWN_TARGETS["openfox"], + pkg_info, + tmp_path, + diagnostics=diagnostics, + ) + + # Verify str_tools + deployed_str = tmp_path / ".openfox" / "agents" / "str_tools.agent.md" + assert deployed_str.exists() + post_str = loads_frontmatter(deployed_str.read_text(encoding="utf-8")) + assert post_str.metadata["id"] == "str_tools" + assert post_str.metadata["allowedTools"] == ["read", "write", "bash"] + + # Verify dict_tools + deployed_dict = tmp_path / ".openfox" / "agents" / "dict_tools.agent.md" + assert deployed_dict.exists() + post_dict = loads_frontmatter(deployed_dict.read_text(encoding="utf-8")) + assert post_dict.metadata["id"] == "dict_tools" + assert post_dict.metadata["allowedTools"] == ["read", "bash"] + + # Verify bad_tools + deployed_bad = tmp_path / ".openfox" / "agents" / "bad_tools.agent.md" + assert deployed_bad.exists() + post_bad = loads_frontmatter(deployed_bad.read_text(encoding="utf-8")) + assert post_bad.metadata["id"] == "bad_tools" + assert "allowedTools" not in post_bad.metadata + + # Verify diagnostics warnings + warnings = diagnostics.by_category().get("warning", []) + assert any("non-boolean value" in w.message and "invalid" in w.message for w in warnings) + assert any("unsupported 'tools' format" in w.message and "bad_tools" in w.message for w in warnings) + + def test_openfox_agent_adoption_requires_normalized_content(self, tmp_path: Path) -> None: + (tmp_path / ".openfox").mkdir() + pkg_dir = tmp_path / "pkg" + pkg_dir.mkdir() + agent_dir = pkg_dir / ".apm" / "agents" + agent_dir.mkdir(parents=True) + + raw_src = "---\nname: Test Agent\ntools:\n- read\n---\nBody content\n" + (agent_dir / "worker.agent.md").write_text(raw_src, encoding="utf-8") + + openfox_agents = tmp_path / ".openfox" / "agents" + openfox_agents.mkdir(parents=True) + target_file = openfox_agents / "worker.agent.md" + + # 1. Pre-place with RAW un-normalized source bytes (missing id, tools not allowedTools) + target_file.write_text(raw_src, encoding="utf-8") + + integrator = AgentIntegrator() + pkg_info = _make_package_info(pkg_dir) + + # Without force, it should collide / skip, NOT adopt + result = integrator.integrate_agents_for_target( + KNOWN_TARGETS["openfox"], + pkg_info, + tmp_path, + managed_files=None, + force=False, + ) + assert result.files_adopted == 0 + assert result.files_skipped == 1 + + # With force, it overwrites and normalizes + result_force = integrator.integrate_agents_for_target( + KNOWN_TARGETS["openfox"], + pkg_info, + tmp_path, + managed_files=None, + force=True, + ) + assert result_force.files_adopted == 0 + assert result_force.files_integrated == 1 + post = loads_frontmatter(target_file.read_text(encoding="utf-8")) + assert post.metadata["id"] == "Test Agent" + assert post.metadata["allowedTools"] == ["read"] + + # 2. Re-install when normalized content matches: should adopt + result_adopt = integrator.integrate_agents_for_target( + KNOWN_TARGETS["openfox"], + pkg_info, + tmp_path, + managed_files={".openfox/agents/worker.agent.md"}, + force=False, + ) + assert result_adopt.files_adopted == 1 + assert result_adopt.files_integrated == 0 + diff --git a/tests/unit/integration/test_targets.py b/tests/unit/integration/test_targets.py index 1d7cead9ce..5f3337e878 100644 --- a/tests/unit/integration/test_targets.py +++ b/tests/unit/integration/test_targets.py @@ -92,6 +92,15 @@ def test_only_opencode_returns_opencode(self): targets = active_targets(self.root) assert [t.name for t in targets] == ["opencode"] + def test_only_openfox_returns_openfox(self): + (self.root / ".openfox").mkdir() + targets = active_targets(self.root) + assert [t.name for t in targets] == ["openfox"] + + def test_explicit_openfox(self): + targets = active_targets(self.root, explicit_target="openfox") + assert [t.name for t in targets] == ["openfox"] + def test_github_and_claude_returns_both(self): (self.root / ".github").mkdir() (self.root / ".claude").mkdir()