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

    acceptedDeprecated: use status/accepted. Kept for issue history; will be removed in milestone 0.10.0.bugDeprecated: use type/bug. Kept for issue history; will be removed in milestone 0.10.0.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions