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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- `apm install` and `apm update` no longer fail with `Object of type mappingproxy is not JSON serializable` when a hook carries nested metadata such as `env`; Claude, Codex, Gemini and Antigravity hook files keep that metadata as plain JSON. (by @abhishekacharya200, closes #3167)
- Codex agent conversion preserves native `model` and `model_reasoning_effort` settings and warns about dropped metadata instead of silently losing it. (#3150)
- Cursor hooks use native v1 events and flat handlers; unsupported input and overlapping Claude imports now fail explicitly. Choose one hook route per dependency; see the [supported mappings](docs/src/content/docs/producer/author-primitives/hooks-and-commands.md#cursor-native-hooks-and-claude-import) and `openapm-v0.1.md` requirements `req-tg-016/017`. (#3149)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,9 @@ hook groups containing a nested `hooks` array in `.codex/hooks.json`. Kiro
receives its current v1 standalone schema:
`{ "version": "v1", "hooks": [{ "name", "trigger", "matcher", "action" }] }`.
Kiro trigger names are PascalCase and command timeouts remain in seconds.
For Claude, Codex, Gemini, and Antigravity, other handler and hook-group
fields, including nested objects and arrays such as `env`, are written to the
native JSON unchanged.

### Cursor native hooks and Claude import

Expand Down
9 changes: 9 additions & 0 deletions src/apm_cli/hook_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ def _freeze(value: Any) -> Any:
return value


def _thaw(value: Any) -> Any:
"""Recursively copy frozen metadata into independent JSON-compatible values."""
if isinstance(value, Mapping):
return {key: _thaw(item) for key, item in value.items()}
if isinstance(value, (list, tuple)):
return [_thaw(item) for item in value]
return value


@dataclass(frozen=True)
class HookHandler:
"""One portable command handler."""
Expand Down
9 changes: 5 additions & 4 deletions src/apm_cli/integration/hook_native_formats.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
HookHandler,
_entries_to_ir,
_handler_to_ir,
_thaw,
hook_handlers,
)

Expand Down Expand Up @@ -350,7 +351,7 @@ def inspect_native_hooks(

def _handler_from_ir(handler: HookHandler, *, timeout_milliseconds: bool) -> dict[str, Any]:
"""Render a portable handler into one native command object."""
result = dict(handler.metadata)
result = _thaw(handler.metadata)
if handler.command is not None:
result["command"] = handler.command
if handler.timeout_seconds is not None:
Expand All @@ -372,9 +373,9 @@ def _render_nested_document(
result: list = []
for binding in document.bindings:
if "raw_entry" in binding.metadata:
result.append(binding.metadata["raw_entry"])
result.append(_thaw(binding.metadata["raw_entry"]))
continue
outer = dict(binding.metadata)
outer = _thaw(binding.metadata)
if binding.matcher is not None or default_matcher is not None:
outer["matcher"] = binding.matcher or default_matcher
outer["hooks"] = [
Expand Down Expand Up @@ -441,7 +442,7 @@ def _to_antigravity_hook_entries(entries: list, event_name: str) -> list:
flat: list[dict[str, Any]] = []
for binding in document.bindings:
if "raw_entry" in binding.metadata:
flat.append(binding.metadata["raw_entry"])
flat.append(_thaw(binding.metadata["raw_entry"]))
continue
for handler in binding.handlers:
rendered = _handler_from_ir(handler, timeout_milliseconds=False)
Expand Down
105 changes: 105 additions & 0 deletions tests/integration/test_architecture_contract_guards.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,111 @@ def test_codex_native_edge_preserves_nested_entries() -> None:
assert _to_codex_hook_entries(source) == source


_NESTED_HOOK_METADATA = {
"env": {"APM_TEST": "value", "PATHS": ["a", "b"]},
"x-extension": {"tags": [{"name": "t"}], "enabled": True, "weight": 1.5, "note": None},
}


def _rendered_handler(target: str) -> tuple[object, dict]:
"""Render one portable handler carrying nested metadata for one native edge."""
from apm_cli.integration.hook_native_formats import (
_to_antigravity_hook_entries,
_to_claude_hook_entries,
_to_codex_hook_entries,
_to_gemini_hook_entries,
)

source = [{"command": "echo ok", "timeoutSec": 3, **_NESTED_HOOK_METADATA}]
if target == "antigravity-flat":
rendered = _to_antigravity_hook_entries(source, "PreInvocation")
return rendered, rendered[0]
renderers = {
"claude": _to_claude_hook_entries,
"codex": _to_codex_hook_entries,
"gemini": _to_gemini_hook_entries,
"antigravity-nested": lambda entries: _to_antigravity_hook_entries(entries, "PreToolUse"),
}
rendered = renderers[target](source)
return rendered, rendered[0]["hooks"][0]


@pytest.mark.parametrize(
("target", "timeout"),
[
("claude", 3),
("codex", 3),
("gemini", 3000),
("antigravity-nested", 3),
("antigravity-flat", 3),
],
)
def test_native_edges_render_nested_hook_metadata_as_json(target: str, timeout: int) -> None:
"""Nested hook metadata must reach native JSON documents intact (#3167)."""
rendered, handler = _rendered_handler(target)

assert json.loads(json.dumps(rendered)) == rendered
assert handler["command"] == "echo ok"
assert handler["timeout"] == timeout
assert handler["env"] == _NESTED_HOOK_METADATA["env"]
assert handler["x-extension"] == _NESTED_HOOK_METADATA["x-extension"]


def test_native_edges_render_nested_binding_metadata_and_raw_entries_as_json() -> None:
"""Matcher-group metadata and raw entries must round-trip through JSON."""
from apm_cli.integration.hook_native_formats import (
_to_antigravity_hook_entries,
_to_codex_hook_entries,
)

nested = [
{
"matcher": "Bash",
"x-group": {"labels": ["a", {"b": 1}]},
"hooks": [{"type": "command", "command": "echo check", "env": {"K": "v"}}],
}
]
raw = [["opaque", {"inner": {"value": 1}}]]

for rendered, expected in (
(_to_codex_hook_entries(nested), nested),
(_to_codex_hook_entries(raw), raw),
(_to_antigravity_hook_entries(raw, "PreInvocation"), raw),
):
assert json.loads(json.dumps(rendered)) == expected


def test_rendered_hook_metadata_is_independent_of_the_frozen_ir() -> None:
"""Mutating native output must not reach the immutable hook IR."""
from apm_cli.hook_contract import _entries_to_ir
from apm_cli.integration.hook_native_formats import _handler_from_ir

document = _entries_to_ir([{"command": "echo ok", **_NESTED_HOOK_METADATA}])
handler = document.bindings[0].handlers[0]

rendered = _handler_from_ir(handler, timeout_milliseconds=False)
rendered["env"]["APM_TEST"] = "changed"
rendered["env"]["PATHS"].append("c")

assert handler.metadata["env"]["APM_TEST"] == "value"
assert tuple(handler.metadata["env"]["PATHS"]) == ("a", "b")
assert (
_handler_from_ir(handler, timeout_milliseconds=False)["env"] == _NESTED_HOOK_METADATA["env"]
)


def test_gemini_compatibility_conversion_preserves_nested_env() -> None:
"""The in-place Gemini key conversion must keep nested env as plain JSON."""
from apm_cli.integration.hook_native_formats import _copilot_keys_to_gemini

hook = {"bash": "echo ok", "timeoutSec": 2, "env": {"APM_TEST": "value"}}
_copilot_keys_to_gemini(hook)

assert hook["env"] == {"APM_TEST": "value"}
assert hook["timeout"] == 2000
assert json.loads(json.dumps(hook)) == hook


def test_claude_edge_nests_flat_portable_hook_entries(tmp_path: Path) -> None:
"""Claude output must wrap portable handlers in matcher/hooks entries."""
from apm_cli.integration.hook_integrator import HookIntegrator
Expand Down