Skip to content

Re-running apm install duplicates hook entries in settings.json #708

@srid

Description

@srid

Summary

Running apm install a second time (without wiping .claude/) appends a duplicate hook entry for every _apm_source-tagged hook already present in .claude/settings.json. The integrator extends the hook list unconditionally instead of upserting by _apm_source, so every re-install grows the file by one entry per hook per package.

Reproducer

apm version: 0.8.11 (current main).

Starting from a clean .claude/settings.json produced by apm install (one Stop hook, _apm_source: "agency", 16 lines), running apm install a second time with no other change produces:

         ],
         "_apm_source": "agency"
+      },
+      {
+        "matcher": "",
+        "hooks": [
+          {
+            "type": "command",
+            "command": ".claude/hooks/agency/scripts/do-stop-guard.sh"
+          }
+        ],
+        "_apm_source": "agency"
       }

The second entry is byte-identical to the first. A third apm install adds a third, and so on.

In my case this is reproducible with the srid/agency APM package (has a single Stop hook), consumed from a kolu checkout. Any package declaring a hook should reproduce it.

Root cause

src/apm_cli/integration/hook_integrator.py:534 — inside _integrate_merged_hooks — unconditionally extends the per-event hook list:

# Mark each entry with APM source for sync/cleanup
for entry in entries:
    if isinstance(entry, dict):
        entry["_apm_source"] = package_name

json_config["hooks"][event_name].extend(entries)

There is no check for existing entries tagged with the same _apm_source. The cleanup path elsewhere in the file (line 730, 775) already knows how to filter by _apm_source marker — the install path doesn't use the same guard.

Expected behaviour

apm install should be idempotent: rerunning it with no changes should leave settings.json byte-identical. Specifically, before .extend(entries), existing entries where entry["_apm_source"] == package_name for this event should be removed (or the new entries merged in place).

Proposed fix

Before the extend at line 534, strip existing entries for the same _apm_source from json_config["hooks"][event_name]. Roughly:

json_config["hooks"][event_name] = [
    e for e in json_config["hooks"][event_name]
    if not (isinstance(e, dict) and e.get("_apm_source") == package_name)
]
json_config["hooks"][event_name].extend(entries)

This matches the cleanup semantics already used by the uninstall/sync paths.

Note on #561 / #562

PR #562 (which closed #561) has "hook dedup" in the title but the body clarifies it is code-level deduplication (collapsing the three copy-pasted integrate_package_hooks_{claude,cursor,codex} methods) with "zero behavioral change". It did not address this issue. Downstream users currently reference #561 in workaround comments believing it tracks this bug — worth clarifying in docs once fixed.

Current workarounds

Consumers work around this today by wiping .claude/settings.json (or the entire .claude/ tree) before apm install, which is destructive and requires a dedicated recipe. See juspay/kolu's agents/ai.just for an example.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions