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
74 changes: 74 additions & 0 deletions src/apm_cli/integration/agent_integrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,10 @@ def integrate_agents_for_target(
links_resolved = self._write_windsurf_agent_skill(
source_file, target_path, diagnostics=diagnostics
)
elif mapping.format_id == "opencode_agent":
links_resolved = self._write_opencode_agent(
source_file, target_path, diagnostics=diagnostics
)
else:
links_resolved = self.copy_agent(source_file, target_path)
total_links_resolved += links_resolved
Expand Down Expand Up @@ -365,6 +369,76 @@ def _write_windsurf_agent_skill(
target.write_text(result, encoding="utf-8")
return links_resolved

# ------------------------------------------------------------------
# OpenCode agent transformer (list tools -> object tools)
# ------------------------------------------------------------------

def _write_opencode_agent(self, source: Path, target: Path, diagnostics=None) -> int:
"""Transform an ``.agent.md`` file for OpenCode's schema.

OpenCode expects the ``tools`` frontmatter field to be an object
(``{Read: true, Glob: true}``) rather than a list
(``["Read", "Glob"]``). This method converts list- or
comma-separated-string ``tools`` to the object format while
keeping the markdown body and non-``tools`` frontmatter keys
intact. YAML comments and key ordering in the frontmatter are
not preserved (the frontmatter is re-serialised after the
``tools`` conversion).
"""
if source.is_symlink():
raise ValueError(f"Refusing to read symlink source: {source}")
content = source.read_text(encoding="utf-8")

fm_match = AgentIntegrator._FRONTMATTER_RE.match(content)
if fm_match:
body = content[fm_match.end() :]
try:
fm = yaml.safe_load(fm_match.group(1)) or {}
Comment thread
futbolsalas15 marked this conversation as resolved.
except yaml.YAMLError:
if diagnostics is not None:
diagnostics.warn(
f"Failed to parse YAML frontmatter in {source.name}, preserving original",
)
fm = {}
Comment thread
futbolsalas15 marked this conversation as resolved.
if not isinstance(fm, dict):
if diagnostics is not None:
diagnostics.warn(
f"Non-dict frontmatter in {source.name}, preserving original",
)
fm = {}
Comment thread
futbolsalas15 marked this conversation as resolved.
else:
body = content
fm = {}

tools = fm.get("tools")
conversion_occurred = False
if tools is not None and not isinstance(tools, dict):
Comment thread
futbolsalas15 marked this conversation as resolved.
if isinstance(tools, list):
fm["tools"] = {t.strip(): True for t in tools if isinstance(t, str) and t.strip()}
conversion_occurred = True
if diagnostics is not None:
diagnostics.info(
f"Converted tools field from list to object in {source.name}",
)
elif isinstance(tools, str):
fm["tools"] = {t.strip(): True for t in tools.split(",") if t.strip()}
conversion_occurred = True
if diagnostics is not None:
diagnostics.info(
f"Converted tools field from string to object in {source.name}",
)

if conversion_occurred:
fm_yaml = yaml.safe_dump(fm, default_flow_style=False, allow_unicode=True).rstrip("\n")
result = f"---\n{fm_yaml}\n---\n" + body
else:
result = content
Comment thread
futbolsalas15 marked this conversation as resolved.

result, links_resolved = self.resolve_links(result, source, target)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(result, encoding="utf-8")
Comment thread
futbolsalas15 marked this conversation as resolved.
return links_resolved

# DEPRECATED: use integrate_agents_for_target(KNOWN_TARGETS["copilot"], ...) instead.
def integrate_package_agents(
self,
Expand Down
193 changes: 193 additions & 0 deletions tests/unit/integration/test_agent_integrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -1088,6 +1088,199 @@ def test_sync_integration_opencode_handles_missing_dir(self):
assert result["errors"] == 0


class TestOpenCodeAgentConversion:
"""Tests for _write_opencode_agent tools list→object conversion."""

def setup_method(self):
self.temp_dir = tempfile.mkdtemp()
self.root = Path(self.temp_dir)
self.integrator = AgentIntegrator()

def teardown_method(self):
import shutil

shutil.rmtree(self.temp_dir, ignore_errors=True)

def test_converts_tools_list_to_object(self):
"""List-format tools becomes an object with true values."""
source = self.root / "cc-correctness.agent.md"
source.write_text(
"---\n"
"name: cc-correctness\n"
"description: Finds correctness bugs\n"
"tools:\n"
" - Read\n"
" - Glob\n"
" - Grep\n"
" - Bash \n"
"model: sonnet\n"
"---\n\n"
"# Agent body\n"
)
target = self.root / "cc-correctness.md"

self.integrator._write_opencode_agent(source, target)

content = target.read_text()
assert "tools:" in content
assert " Read: true" in content
assert " Glob: true" in content
assert " Grep: true" in content
assert " Bash: true" in content
assert "name: cc-correctness" in content
assert "description: Finds correctness bugs" in content
assert "model: sonnet" in content
assert "# Agent body" in content

def test_converts_comma_separated_tools_string(self):
"""Comma-separated string tools becomes an object."""
source = self.root / "agent.agent.md"
source.write_text('---\nname: my-agent\ntools: "Read, Glob, Grep"\n---\n\nBody text\n')
target = self.root / "agent.md"

self.integrator._write_opencode_agent(source, target)

content = target.read_text()
assert "Read: true" in content
assert "Glob: true" in content
assert "Grep: true" in content

def test_preserves_no_tools_field(self):
"""Absent tools field leaves frontmatter unchanged."""
source = self.root / "simple.agent.md"
source.write_text("---\nname: simple\ndescription: A simple agent\n---\n\n# Simple\n")
target = self.root / "simple.md"

self.integrator._write_opencode_agent(source, target)

content = target.read_text()
assert "tools:" not in content
assert "name: simple" in content
assert "# Simple" in content

def test_leaves_tools_object_unchanged(self):
"""Already-object tools is left as-is."""
source = self.root / "already.agent.md"
source.write_text("---\nname: already\ntools:\n Read: true\n Bash: true\n---\n\n# Body\n")
target = self.root / "already.md"

self.integrator._write_opencode_agent(source, target)

content = target.read_text()
assert "Read: true" in content
assert "Bash: true" in content
assert "tools:" in content

def test_converts_empty_tools_list_to_empty_object(self):
"""Empty list becomes empty object {}."""
source = self.root / "empty.agent.md"
source.write_text("---\nname: empty\ntools: []\n---\n\n# Body\n")
target = self.root / "empty.md"

self.integrator._write_opencode_agent(source, target)

content = target.read_text()
assert "tools: {}" in content

def test_no_frontmatter_copies_verbatim(self):
"""File without frontmatter is copied verbatim."""
source = self.root / "plain.agent.md"
source.write_text("# Plain agent\n\nJust instructions.\n")
target = self.root / "plain.md"

self.integrator._write_opencode_agent(source, target)

assert target.read_text() == "# Plain agent\n\nJust instructions.\n"

def test_frontmatter_body_preserved_verbatim(self):
"""Markdown body after frontmatter is preserved."""
source = self.root / "body.agent.md"
body = "\n## Instructions\n\n1. Read files\n2. Grep for patterns\n3. Report\n"
source.write_text(f"---\nname: checker\ntools:\n - Read\n - Grep\n---\n{body}")
target = self.root / "body.md"

self.integrator._write_opencode_agent(source, target)

content = target.read_text()
assert "## Instructions" in content
assert "1. Read files" in content
assert "2. Grep for patterns" in content
assert "3. Report" in content

def test_rejects_symlink_source(self):
"""Symlink source raises ValueError."""
import os

import pytest

real = self.root / "real.agent.md"
real.write_text("---\ntools:\n - Foo\n---\n\nBody\n")
link = self.root / "link.agent.md"
try:
os.symlink(real, link)
except (OSError, NotImplementedError):
pytest.skip("symlink creation not supported on this platform")
target = self.root / "out.md"

with pytest.raises(ValueError, match="symlink"):
self.integrator._write_opencode_agent(link, target)

def test_handles_non_dict_frontmatter(self):
"""Non-dict YAML frontmatter (e.g. bare list) is preserved as-is."""
source = self.root / "badfm.agent.md"
# frontmatter that parses as a YAML list, not a mapping
source.write_text("---\n- one\n- two\n---\n\n# Body\n")
target = self.root / "badfm.md"

self.integrator._write_opencode_agent(source, target)

content = target.read_text()
# Should not crash; non-dict frontmatter is preserved as-is
assert "---" in content
Comment thread
futbolsalas15 marked this conversation as resolved.
assert "# Body" in content
assert "- one" in content
assert "- two" in content

def test_integrate_via_target_dispatch(self):
"""End-to-end: opencode target triggers tools conversion."""
from apm_cli.integration.targets import KNOWN_TARGETS

(self.root / ".opencode").mkdir()
pkg = self.root / "package"
agents_dir = pkg / ".apm" / "agents"
agents_dir.mkdir(parents=True)
(agents_dir / "security.agent.md").write_text(
"---\nname: security\ntools:\n - Read\n - Grep\n---\n\n# Security Agent\n"
)

package = APMPackage(name="test-pkg", version="1.0.0", package_path=pkg)
resolved_ref = ResolvedReference(
original_ref="main",
ref_type=GitReferenceType.BRANCH,
resolved_commit="abc123",
ref_name="main",
)
package_info = PackageInfo(
package=package,
install_path=pkg,
resolved_reference=resolved_ref,
installed_at="2024-01-01T00:00:00",
)

opencode_target = KNOWN_TARGETS["opencode"]
result = self.integrator.integrate_agents_for_target(
opencode_target, package_info, self.root
)

assert result.files_integrated == 1
deployed = self.root / ".opencode" / "agents" / "security.md"
assert deployed.exists()
content = deployed.read_text()
assert "Read: true" in content
assert "Grep: true" in content
assert "# Security Agent" in content


class TestCodexAgentIntegration:
"""Tests for Codex TOML agent transformation."""

Expand Down
Loading