Skip to content
Merged
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
25 changes: 25 additions & 0 deletions docs/src/content/docs/guides/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,31 @@ By default APM looks for `agents/`, `skills/`, `commands/`, and `hooks/` directo
- For **agents**, directory contents are flattened into `.apm/agents/` (agents are flat files, not named directories)
- `hooks` also accepts an inline object: `"hooks": {"hooks": {"PreToolUse": [...]}}`

##### Target-specific hook files

When a package ships hooks for multiple tools, use target-specific filenames so
each tool receives only its own hooks:

| Filename pattern | Deployed to |
|---|---|
| `*-copilot-hooks.json` | GitHub Copilot only |
| `*-cursor-hooks.json` | Cursor only |
| `*-claude-hooks.json` | Claude Code only |
| `*-codex-hooks.json` | Codex CLI only |
| `*-gemini-hooks.json` | Gemini CLI only |
| Any other name (e.g. `hooks.json`, `telemetry-hooks.json`) | All targets |

Example directory tree for a multi-target hook package:

```
my-hooks-pkg/
hooks/
hooks.json # deployed to all targets
copilot-hooks.json # Copilot only
cursor-hooks.json # Cursor only
claude-hooks.json # Claude Code only
```

#### MCP Server Definitions

Plugins can ship MCP servers that are automatically deployed through APM's MCP pipeline. Define servers using `mcpServers` in `plugin.json`:
Expand Down
32 changes: 32 additions & 0 deletions packages/apm-guide/.apm/skills/apm-usage/package-authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,38 @@ my-package/
resource2.md
```

## Hook files

Packages can ship hooks (pre/post tool-use scripts) by placing JSON
files under `hooks/` or `.apm/hooks/`. When a package targets multiple
tools, use target-specific filenames so each tool receives only its own
hooks:

| Filename pattern | Deployed to |
|---|---|
| `*-copilot-hooks.json` | GitHub Copilot only |
| `*-cursor-hooks.json` | Cursor only |
| `*-claude-hooks.json` | Claude Code only |
| `*-codex-hooks.json` | Codex CLI only |
| `*-gemini-hooks.json` | Gemini CLI only |
| Any other name (e.g. `hooks.json`, `telemetry-hooks.json`) | All targets |

Example directory tree for a multi-target hook package:

```
my-hooks-pkg/
hooks/
hooks.json # deployed to all targets
copilot-hooks.json # Copilot only
cursor-hooks.json # Cursor only
claude-hooks.json # Claude Code only
```

APM automatically normalises event names per target (e.g. `postToolUse`
becomes `PostToolUse` in Claude) and rewrites path variables
(`${PLUGIN_ROOT}`, `${CURSOR_PLUGIN_ROOT}`, `${CLAUDE_PLUGIN_ROOT}`) to
the correct target-specific form.

## Manifest fields: `target:` validation contract

The `target:` field in `apm.yml` controls which output runtimes the package
Expand Down
106 changes: 101 additions & 5 deletions src/apm_cli/integration/hook_integrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@
}

Script path handling:
- ${CLAUDE_PLUGIN_ROOT}/path -> resolved relative to package root, rewritten for target
- ${CLAUDE_PLUGIN_ROOT}/path, ${CURSOR_PLUGIN_ROOT}/path, ${PLUGIN_ROOT}/path
-> resolved relative to package root, rewritten for target
- ./path -> relative path, resolved from hook file's parent directory, rewritten for target
- System commands (no path separators) -> passed through unchanged
"""
Expand Down Expand Up @@ -89,6 +90,11 @@ class _MergeHookConfig:
# Copilot (camelCase) or Claude (PascalCase) names; targets that use
# different conventions get their events renamed during merge.
_HOOK_EVENT_MAP: dict[str, dict[str, str]] = {
"claude": {
# Copilot camelCase -> Claude PascalCase
"preToolUse": "PreToolUse",
"postToolUse": "PostToolUse",
},
"gemini": {
# Copilot / Claude -> Gemini
"PreToolUse": "BeforeTool",
Expand Down Expand Up @@ -167,6 +173,53 @@ def _copilot_keys_to_gemini(hook: dict) -> None:
}


# Mapping from hook-file stem suffix to the set of target keys that
# should receive the file. Files whose stem does not match any
# suffix are treated as universal and deployed to every target.
_HOOK_FILE_TARGET_SUFFIXES: dict[str, set[str]] = {
"copilot-hooks": {"copilot", "vscode"},
"cursor-hooks": {"cursor"},
"claude-hooks": {"claude"},
"codex-hooks": {"codex"},
"gemini-hooks": {"gemini"},
}


def _filter_hook_files_for_target(
hook_files: List[Path],
target_key: str,
) -> List[Path]:
"""Return only hook files intended for *target_key*.

Routing is based on the file stem (case-insensitive):
- Stems ending with a known ``-<target>-hooks`` suffix are
restricted to matching targets.
- All other stems (e.g. ``hooks``, ``my-custom-hooks``) are
universal and pass through for every target.

Args:
hook_files: All discovered hook JSON files.
target_key: Lowercase target name (e.g. ``"claude"``, ``"cursor"``).

Returns:
Filtered list preserving original order.
"""
result: List[Path] = []
for hf in hook_files:
stem_lower = hf.stem.lower()
matched_suffix: str | None = None
for suffix, allowed_targets in _HOOK_FILE_TARGET_SUFFIXES.items():
if stem_lower == suffix or stem_lower.endswith(f"-{suffix}"):
matched_suffix = suffix
if target_key in allowed_targets:
result.append(hf)
break
if matched_suffix is None:
# Universal file -- deploy to all targets
result.append(hf)
return result


class HookIntegrator(BaseIntegrator):
"""Handles integration of APM package hooks into target locations.

Expand Down Expand Up @@ -295,10 +348,10 @@ def _rewrite_command_for_target(
base_root = root_dir or ".claude"
scripts_base = f"{base_root}/hooks/{package_name}"

# Handle ${CLAUDE_PLUGIN_ROOT} references (always relative to package root)
# Handle plugin root variable references (always relative to package root)
# Match both forward-slash and backslash separators (Windows hook JSON
# may use backslashes: ${CLAUDE_PLUGIN_ROOT}\scripts\scan.ps1)
plugin_root_pattern = r'\$\{CLAUDE_PLUGIN_ROOT\}([\\/][^\s]+)'
plugin_root_pattern = r'\$\{(?:CLAUDE_PLUGIN_ROOT|CURSOR_PLUGIN_ROOT|PLUGIN_ROOT)\}([\\/][^\s]+)'
for match in re.finditer(plugin_root_pattern, command):
full_var = match.group(0)
# Normalize backslashes to forward slashes before Path construction
Expand Down Expand Up @@ -452,6 +505,7 @@ def integrate_package_hooks(self, package_info, project_root: Path,
HookIntegrationResult: Results of the integration operation
"""
hook_files = self.find_hook_files(package_info.install_path)
hook_files = _filter_hook_files_for_target(hook_files, "copilot")

if not hook_files:
return HookIntegrationResult(
Expand Down Expand Up @@ -547,6 +601,7 @@ def _integrate_merged_hooks(
return _empty

hook_files = self.find_hook_files(package_info.install_path)
hook_files = _filter_hook_files_for_target(hook_files, config.target_key)
if not hook_files:
return _empty

Expand All @@ -556,7 +611,7 @@ def _integrate_merged_hooks(
target_paths: List[Path] = []
# Events whose prior-owned entries have already been cleared on
# this install run. Packages can contribute to the same event
# from multiple hook files we must only strip once so earlier
# from multiple hook files -- we must only strip once so earlier
# files' fresh entries aren't wiped by later iterations.
cleared_events: set = set()

Expand Down Expand Up @@ -589,6 +644,12 @@ def _integrate_merged_hooks(
# Merge hooks into config (additive)
hooks = rewritten.get("hooks", {})
event_map = _HOOK_EVENT_MAP.get(config.target_key, {})

# Build reverse map: normalised name -> set of source aliases
reverse_map: dict[str, set[str]] = {}
for source_name, norm_name in event_map.items():
reverse_map.setdefault(norm_name, set()).add(source_name)

for raw_event_name, entries in hooks.items():
if not isinstance(entries, list):
continue
Expand All @@ -609,18 +670,53 @@ def _integrate_merged_hooks(
# package before appending fresh ones. Without this, every
# `apm install` re-run duplicates the package's hooks
# because `.extend()` is unconditional. See microsoft/apm#708.
# Only strip once per event per install run a package
# Only strip once per event per install run -- a package
# with multiple hook files targeting the same event
# contributes each file's entries in turn, and stripping
# on every iteration would erase earlier files' work.
if event_name not in cleared_events:
# Clear from the normalised event
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)
]
# Also clear from any alias events that map to
# this normalised name (handles migration from
# corrupted installs with mixed-case event keys).
for alias in reverse_map.get(event_name, set()):
if alias != event_name and alias in json_config["hooks"]:
json_config["hooks"][alias] = [
e for e in json_config["hooks"][alias]
if not (isinstance(e, dict) and e.get("_apm_source") == package_name)
]
# Remove the alias key entirely if now empty
if not json_config["hooks"][alias]:
del json_config["hooks"][alias]
cleared_events.add(event_name)
json_config["hooks"][event_name].extend(entries)

# Deduplicate same-package entries by content.
# Safety net for edge cases where multiple source files
# produce semantically identical entries.
seen_content: list[dict] = []
deduped: list = []
for entry in json_config["hooks"][event_name]:
if not isinstance(entry, dict):
deduped.append(entry)
continue
# Build comparison key (all fields except _apm_source)
cmp = {k: v for k, v in sorted(entry.items()) if k != "_apm_source"}
source = entry.get("_apm_source")
is_dup = False
for seen in seen_content:
if seen.get("_source") == source and seen.get("_cmp") == cmp:
is_dup = True
break
if not is_dup:
seen_content.append({"_source": source, "_cmp": cmp})
Comment on lines +701 to +716
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new content-based deduplication runs over the entire event list and dedups entries for any _apm_source value (including other packages) and also user-authored entries that have no _apm_source (treated as None). This can unintentionally remove duplicates that are not owned by the package currently being installed. Dedup should be scoped to the current package only (e.g., only consider/remove duplicates among entries where _apm_source == package_name, leaving other entries untouched).

Suggested change
seen_content: list[dict] = []
deduped: list = []
for entry in json_config["hooks"][event_name]:
if not isinstance(entry, dict):
deduped.append(entry)
continue
# Build comparison key (all fields except _apm_source)
cmp = {k: v for k, v in sorted(entry.items()) if k != "_apm_source"}
source = entry.get("_apm_source")
is_dup = False
for seen in seen_content:
if seen.get("_source") == source and seen.get("_cmp") == cmp:
is_dup = True
break
if not is_dup:
seen_content.append({"_source": source, "_cmp": cmp})
#
# Scope deduplication to entries owned by the package being
# installed. Leave entries from other packages and
# user-authored entries without _apm_source untouched.
seen_content: list[dict] = []
deduped: list = []
for entry in json_config["hooks"][event_name]:
if not isinstance(entry, dict):
deduped.append(entry)
continue
if entry.get("_apm_source") != package_name:
deduped.append(entry)
continue
# Build comparison key for package-owned entries
# only (all fields except _apm_source).
cmp = {
k: v for k, v in sorted(entry.items()) if k != "_apm_source"
}
is_dup = False
for seen in seen_content:
if seen == cmp:
is_dup = True
break
if not is_dup:
seen_content.append(cmp)

Copilot uses AI. Check for mistakes.
deduped.append(entry)
json_config["hooks"][event_name] = deduped

hooks_integrated += 1

# Copy referenced scripts
Expand Down
Loading
Loading