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

## [Unreleased]

### Fixed

- `apm install` and `apm update` now write untyped flat command hooks as Codex `"type": "command"` handlers in `.codex/hooks.json`, as Codex's hook schema requires, including alongside Codex-shaped hook files. Reinstalling replaces older APM-owned untyped entries; explicit types, already-nested source handlers, manual hooks and other targets are unchanged. -- by @Parth-Vasave (closes #3249)

## [0.35.0] - 2026-10-22

### Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,12 +196,13 @@ Supported targets and where the integrator writes:
APM parses the source into vendor-neutral hook intent, then each target
integrator renders its native schema. Flat command entries become Claude's
required `{ "matcher": "*", "hooks": [...] }` entries in
`.claude/settings.json`; a flat command entry that omits `type` receives
Claude's required `"type": "command"`, while explicit handler types are kept
and other targets are unchanged. This default applies only to flat command
entries, not handlers already inside nested Claude groups. For Codex, flat command entries become
`.claude/settings.json`. For Codex, flat command entries become
intermediate hook groups containing a nested `hooks` array in
`.codex/hooks.json`. Kiro receives its current v1 standalone schema:
`.codex/hooks.json`. For both Claude and Codex, a flat command entry that
omits `type` receives the required `"type": "command"`, while explicit handler
types are kept and other targets are unchanged. This default applies only to
flat command entries, not handlers already inside nested source groups;
supply `type` yourself when authoring nested groups. 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,9 @@ when a flat command entry omits `type`. Explicit handler types are kept.
Handlers already inside nested Claude groups and other targets are unchanged.

For Codex, APM wraps flat command entries in hook groups containing a
nested `hooks` array in `.codex/hooks.json`.
nested `hooks` array in `.codex/hooks.json` and adds the required
`"type": "command"` when a flat command entry omits `type`. Explicit handler
types are kept. Handlers already inside nested Codex groups are unchanged.
Claude, Codex, Gemini, and Antigravity preserve nested handler and
hook-group metadata, including `env` objects and arrays, in native JSON.

Expand Down
24 changes: 17 additions & 7 deletions src/apm_cli/integration/hook_native_formats.py
Original file line number Diff line number Diff line change
Expand Up @@ -416,12 +416,15 @@ def _legacy_hook_entries(entries: list, target: str) -> list:
"""Render old native shapes for ownership matching, never for deployment."""
if target == "claude":
return _to_claude_hook_entries(entries, default_handler_type=False)
# Codex installs before native nesting wrote the original flat entries.
return entries if target == "codex" else []
if target == "codex":
# Codex installs wrote the original flat entries before native nesting,
# then nested them without typing untyped flat command entries.
return [*entries, *_to_codex_hook_entries(entries, default_handler_type=False)]
return []


def _with_claude_default_handler_type(entry: object) -> object:
"""Supply Claude's required ``type`` for an untyped flat command entry.
def _with_default_command_type(entry: object) -> object:
"""Supply the required ``type`` for an untyped flat command entry.

Only flat entries that the neutral grammar reads as a command handler are
defaulted; explicit types, nested groups, and non-command entries pass
Expand All @@ -442,16 +445,23 @@ def _to_claude_hook_entries(entries: list, *, default_handler_type: bool = True)
still be matched on reinstall.
"""
if default_handler_type:
entries = [_with_claude_default_handler_type(entry) for entry in entries]
entries = [_with_default_command_type(entry) for entry in entries]
return _render_nested_document(
_entries_to_ir(entries),
timeout_milliseconds=False,
default_matcher="*",
)


def _to_codex_hook_entries(entries: list) -> list:
"""Render portable bindings in Codex's nested hook schema."""
def _to_codex_hook_entries(entries: list, *, default_handler_type: bool = True) -> list:
"""Render portable bindings in Codex's nested hook schema.

``default_handler_type=False`` reproduces the output of installs before
untyped flat command entries were typed, so owned entries they wrote can
still be matched on reinstall.
"""
if default_handler_type:
entries = [_with_default_command_type(entry) for entry in entries]
return _render_nested_document(
_entries_to_ir(entries),
timeout_milliseconds=False,
Expand Down
16 changes: 16 additions & 0 deletions tests/integration/test_architecture_contract_guards.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,22 @@ def test_codex_native_edge_preserves_nested_entries() -> None:
assert _to_codex_hook_entries(source) == source


def test_codex_native_edge_types_untyped_flat_commands() -> None:
"""Codex handlers are type-tagged, so flat commands need ``type`` (#3249)."""
from apm_cli.integration.hook_native_formats import (
_legacy_hook_entries,
_to_codex_hook_entries,
)

source = [{"command": "echo check", "matcher": "Bash"}]
untyped = [{"matcher": "Bash", "hooks": [{"command": "echo check"}]}]

assert _to_codex_hook_entries(source) == [
{"matcher": "Bash", "hooks": [{"type": "command", "command": "echo check"}]}
]
assert _legacy_hook_entries(source, "codex") == [*source, *untyped]


_NESTED_HOOK_METADATA = {
"env": {"APM_TEST": "value", "PATHS": ["a", "b"]},
"x-extension": {"tags": [{"name": "t"}], "enabled": True, "weight": 1.5, "note": None},
Expand Down
117 changes: 117 additions & 0 deletions tests/integration/test_required_lifecycle_state_machine.py
Original file line number Diff line number Diff line change
Expand Up @@ -1938,6 +1938,123 @@ def test_required_claude_untyped_hook_upgrade_preserves_ownership_and_converges(
baseline = snapshot


@pytest.mark.parametrize("legacy_shape", ["nested-untyped", "flat"])
@pytest.mark.parametrize("with_native_group", [False, True], ids=["standalone", "merged"])
def test_required_codex_untyped_hook_upgrade_preserves_ownership_and_converges(
tmp_path: Path, apm_binary_path: Path, with_native_group: bool, legacy_shape: str
) -> None:
"""Real installs and updates heal pre-fix Codex handlers without claiming user hooks."""
scenario = _new_scenario(tmp_path / "codex-untyped", apm_binary_path)
source = _publish(scenario, "flat-kit", hook_command="echo native")
hook_root = source.repository.worktree / ".apm" / "hooks"
if not with_native_group:
(hook_root / "pretool.json").unlink()
command = "echo must-not-run > hook-executed"
flat = {"command": command, "matcher": "Write", "timeout": 10}
(hook_root / "flat.json").write_text(
json.dumps({"hooks": {"PreToolUse": [flat]}}), encoding="ascii"
)
scenario.repositories.commit(source.repository, message="publish untyped flat hook")
consumer = scenario.consumers.create(
"codex-consumer",
dependencies=({**source.dependency, "ref": "main"},),
targets=("codex",),
)
scenario.consumers.add_hook(
consumer, "root", {"hooks": {"PreToolUse": [{"command": "echo root"}]}}
)
hooks_path = consumer.root / ".codex/hooks.json"
sidecar_path = consumer.root / ".codex/apm-hooks.json"
hooks_path.parent.mkdir()
user_group = {"matcher": "Read", "hooks": [{"type": "prompt"}]}
manual_untyped = {"matcher": "Edit", "hooks": [{"command": "echo manual"}]}
hooks_path.write_text(
json.dumps({"hooks": {"PreToolUse": [user_group, manual_untyped]}}), encoding="ascii"
)
sentinel = consumer.root / ".claude/settings.json"
sentinel.parent.mkdir()
sentinel.write_bytes(b'{"hooks": {}, "user": "unchanged"}\n')
authored = ArtifactSnapshot.capture(consumer.root / ".apm")
owned_commands = {command, "echo root"}

_run_success(
scenario,
consumer,
(*_INSTALL_ARGS, "--target", "codex"),
environment=source.environment,
scenario_id="codex-untyped-install",
)
expected_hooks = json.loads(hooks_path.read_text(encoding="utf-8"))
expected_sidecar = json.loads(sidecar_path.read_text(encoding="utf-8"))
groups = expected_hooks["hooks"]["PreToolUse"]
handlers = [handler for group in groups for handler in group["hooks"]]
assert {"type": "command", "command": command, "timeout": 10} in handlers
assert {"type": "command", "command": "echo root"} in handlers
assert groups[:2] == [user_group, manual_untyped]
assert len(groups) == 4 + int(with_native_group)
assert {entry["_apm_source"] for entry in expected_sidecar["PreToolUse"]} == {
f"{_OWNER}/flat-kit",
"_local/codex-consumer",
}
assert all(
entry["hooks"] not in (user_group["hooks"], manual_untyped["hooks"])
for entry in expected_sidecar["PreToolUse"]
)

def write_legacy_output() -> None:
"""Recreate the previous renderer's untyped output and a renamed root source."""
legacy_hooks = json.loads(hooks_path.read_text(encoding="utf-8"))
legacy_sidecar = json.loads(sidecar_path.read_text(encoding="utf-8"))
for entries in (legacy_hooks["hooks"]["PreToolUse"], legacy_sidecar["PreToolUse"]):
for index, entry in enumerate(entries):
handler = entry["hooks"][0]
if handler.get("command") not in owned_commands:
continue
handler.pop("type")
if legacy_shape == "flat":
group = {key: value for key, value in entry.items() if key != "hooks"}
entries[index] = entry = {**handler, **group}
if entry.get("_apm_source") == "_local/codex-consumer":
entry["_apm_source"] = "old-checkout-name"
hooks_path.write_text(json.dumps(legacy_hooks), encoding="ascii")
sidecar_path.write_text(json.dumps(legacy_sidecar), encoding="ascii")

def assert_converged() -> None:
assert json.loads(hooks_path.read_text(encoding="utf-8")) == expected_hooks
assert json.loads(sidecar_path.read_text(encoding="utf-8")) == expected_sidecar
assert sentinel.read_bytes() == b'{"hooks": {}, "user": "unchanged"}\n'
assert not (consumer.root / "hook-executed").exists()
assert_unchanged(authored, ArtifactSnapshot.capture(consumer.root / ".apm"))

write_legacy_output()
baseline = None
for cycle in range(2):
_run_success(
scenario,
consumer,
(*_INSTALL_ARGS, "--target", "codex"),
environment=source.environment,
scenario_id=f"codex-untyped-reinstall-{cycle}",
)
assert_converged()
snapshot = LifecycleStateSnapshot.capture(consumer.root, targets=("codex",))
if baseline is not None:
_assert_same_state(baseline, snapshot)
baseline = snapshot

commit_b = _publish_revision(scenario, source, "b")
write_legacy_output()
_run_success(
scenario,
consumer,
("update", "--yes", "--parallel-downloads", "0"),
environment=source.environment,
scenario_id="codex-untyped-update",
)
assert commit_b.sha in (consumer.root / "apm.lock.yaml").read_text(encoding="utf-8")
assert_converged()


@pytest.mark.parametrize("alias", [".safe", "safe.", "foo..bar", "my-skill.v2"])
def test_required_reinstall_is_byte_idempotent_across_durable_state(
tmp_path: Path,
Expand Down
15 changes: 8 additions & 7 deletions tests/unit/integration/test_hook_integrator_issue3130.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
- Explicit handler types and untyped entries that are not command handlers
are preserved unchanged.
- Untyped handlers already inside a nested Claude group are left unchanged.
- Other targets do not receive the Claude-only default.
- Targets without a required handler ``type`` do not receive the default
(Codex shares it since #3249).
- Reinstalling over settings written before the fix leaves one typed group,
including content-matched healing of stale root-package entries.
"""
Expand Down Expand Up @@ -199,22 +200,22 @@ def test_untyped_handler_inside_nested_claude_group_is_unchanged(tmp_path: Path)
@pytest.mark.parametrize(
("target", "config_dir", "config_file"),
[
pytest.param("codex", ".codex", "hooks.json", id="codex"),
pytest.param("cursor", ".cursor", "hooks.json", id="cursor"),
pytest.param("gemini", ".gemini", "settings.json", id="gemini"),
],
)
def test_claude_handler_default_does_not_reach_other_targets(
def test_handler_type_default_does_not_reach_other_targets(
tmp_path: Path, target: str, config_dir: str, config_file: str
) -> None:
(tmp_path / config_dir).mkdir()
package_info = _write_package(tmp_path, {"flat.json": _FLAT_UNTYPED_HOOK})

with patch(
"apm_cli.integration.hook_native_formats._with_claude_default_handler_type",
side_effect=AssertionError("Claude handler typing reached another target"),
) as claude_default:
"apm_cli.integration.hook_native_formats._with_default_command_type",
side_effect=AssertionError("handler typing reached another target"),
) as default_type:
_integrate(tmp_path, package_info, target)
claude_default.assert_not_called()
default_type.assert_not_called()

config = _read_json(tmp_path / config_dir / config_file)
rendered = json.dumps(config["hooks"])
Expand Down
Loading