Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/apm_cli/bundle/lockfile_enrichment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/",
Expand Down
1 change: 1 addition & 0 deletions src/apm_cli/commands/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ def _detect_agentrc(project_root: Path) -> tuple[bool, bool]:
"claude",
"cursor",
"opencode",
"openfox",
"codex",
"gemini",
"windsurf",
Expand Down
1 change: 1 addition & 0 deletions src/apm_cli/compilation/context_optimizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
".github",
".kiro",
".opencode",
".openfox",
".windsurf",
}
)
Expand Down
1 change: 1 addition & 0 deletions src/apm_cli/core/deployment_ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
".gemini/": "gemini",
".codex/": "codex",
".opencode/": "opencode",
".openfox/": "openfox",
}
_LEGACY_USER_TARGET_PREFIXES = {
".copilot/": "copilot",
Expand Down
2 changes: 1 addition & 1 deletion src/apm_cli/core/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/"
)


Expand Down
7 changes: 7 additions & 0 deletions src/apm_cli/core/target_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
21 changes: 19 additions & 2 deletions src/apm_cli/core/target_detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ def agents_alias_was_detected() -> bool:
"hermes",
"kiro",
"opencode",
"openfox",
"vscode",
"windsurf",
"all",
Expand Down Expand Up @@ -118,14 +119,15 @@ def agents_alias_was_detected() -> bool:
"hermes",
"kiro",
"opencode",
"openfox",
"vscode",
"windsurf",
"all",
"minimal",
]


def detect_target( # noqa: PLR0911
def detect_target( # noqa: C901, PLR0911
project_root: Path,
explicit_target: str | None = None,
config_target: str | None = None,
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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":
Expand All @@ -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()
Expand All @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -281,6 +292,7 @@ def should_compile_agents_md(target: CompileTargetType) -> bool:
"vscode",
"cursor",
"opencode",
"openfox",
"codex",
"gemini",
"antigravity",
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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"),
]
Expand All @@ -1077,6 +1091,7 @@ def resolve_manifest_target_decision(
"gemini",
"grok-build",
"opencode",
"openfox",
"windsurf",
"kiro",
]
Expand All @@ -1090,6 +1105,7 @@ def resolve_manifest_target_decision(
"gemini": ".gemini/",
"grok-build": ".grok/",
"opencode": ".opencode/",
"openfox": ".openfox/",
"windsurf": ".windsurf/",
"kiro": ".kiro/",
}
Expand All @@ -1104,6 +1120,7 @@ def resolve_manifest_target_decision(
"gemini": "GEMINI.md",
"grok-build": ".grok/",
"opencode": ".opencode/",
"openfox": ".openfox/",
"windsurf": ".windsurf/",
"kiro": ".kiro/",
}
Expand Down
134 changes: 131 additions & 3 deletions src/apm_cli/integration/agent_integrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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(
Expand Down Expand Up @@ -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)
# ------------------------------------------------------------------
Expand Down
17 changes: 17 additions & 0 deletions src/apm_cli/integration/targets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/.
Expand Down
1 change: 1 addition & 0 deletions src/apm_cli/models/dependency/host_virtual.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
".gemini",
".github",
".kiro",
".openfox",
".windsurf",
}
)
Expand Down
1 change: 1 addition & 0 deletions src/apm_cli/policy/policy_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -777,6 +777,7 @@ def _check_scripts_policy(
".cursor/rules",
".claude",
".opencode",
".openfox",
".kiro",
]

Expand Down
Loading