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

- `apm update` against private Azure DevOps deps no longer fails on Windows with a misleading "az present but not logged in" diagnostic when the user IS signed in via `az login`. Root cause: Python's `subprocess.run(["az", ...])` -> `CreateProcessW` does not honor `PATHEXT` for non-`.exe` executables, so the Windows `az.cmd` wrapper could not be invoked even though `shutil.which("az")` resolved it. `AzureCliBearerProvider` now resolves the `az` binary via `shutil.which` once at construction and passes the absolute path to every subprocess call. As a defense-in-depth measure, the ADO `--update` preflight probe no longer strips `GIT_CONFIG_GLOBAL` / `GIT_CONFIG_NOSYSTEM` / `GIT_ASKPASS`, so Git Credential Manager can answer for Entra-cached ADO credentials whenever bearer acquisition is unavailable for any reason (sandbox, proxy, future PATH quirks). The actual clone path keeps its full gitconfig isolation. (#1430)
- Root `.apm` hooks no longer duplicate after renaming the project directory or using git worktrees; Claude, Codex, Cursor, Gemini, and Windsurf hook configs stay idempotent across checkouts. The hook source-id is now derived from `apm.yml`'s `name` field instead of `install_path.name`, and `apm install` silently heals stale same-content entries from prior checkout basenames. Copilot is unaffected (its hooks live in per-file namespaces under `.github/hooks/`, not a shared merged config). (#1392, closes #1329)
- `apm install` (project-scope) keeps hook `command` paths repo-relative again, so checked-in `.claude/settings.json`, `.codex/hooks.json`, and equivalents stay portable across clones, contributors, and CI runners; user-scope (`apm install -g`) still writes absolute paths (#1310 / #1354 preserved). Re-run `apm install` on existing repos to rewrite any committed absolutized configs back to repo-relative paths. (#1394)

## [0.14.1] - 2026-05-20

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,13 +151,17 @@ agent a procedure" fits a skill -- and reaches every harness.
- **Script paths.** Use `${PLUGIN_ROOT}` (or the harness-specific
alias) for scripts that ship inside the package. Plain absolute
paths break on consumers' machines.
- **Claude target path resolution.** When installing for the Claude
target, `apm install` rewrites `${PLUGIN_ROOT}` and relative `./`
references to absolute paths so Claude Code can execute scripts
regardless of the working directory. Scripts that are missing at
install time are replaced with their expected absolute path and a
warning is emitted — the hook is written, but the script will fail
at runtime until the file is present.
- **Hook script path resolution.** `apm install -g` (user-scope)
rewrites `${PLUGIN_ROOT}` and relative `./` references to absolute
paths so Claude Code can execute scripts regardless of the working
directory. Project-scope `apm install` (no `-g`) keeps `command`
paths repo-relative so checked-in configs stay portable across
clones, contributors, and CI. Either way, if a referenced script
is missing at install time the installer emits a warning -- in
user-scope the unexpanded variable is rewritten to the absolute
source path so the hook fails loudly at runtime; in project-scope
the variable is left in place so the deployed config never embeds
the installer's machine-local prefix.
- **Same `.prompt.md` is two primitives.** A single
`.apm/prompts/foo.prompt.md` becomes Copilot's prompt and Claude's
`/foo` command in the same install. Name files with both surfaces
Expand Down
19 changes: 15 additions & 4 deletions src/apm_cli/install/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ def integrate_package_primitives(
"""
from apm_cli.integration.dispatch import get_dispatch_table

from ..core.scope import InstallScope

_dispatch = get_dispatch_table()
result = {
"prompts": 0,
Expand Down Expand Up @@ -265,14 +267,23 @@ def _format_target_collapse(paths: list[str], verbose: bool) -> tuple[str, list[
_mapping = _target.primitives.get(_prim_name)
if _mapping is None:
continue
_call_kwargs: dict[str, Any] = {
"force": force,
"managed_files": managed_files,
"diagnostics": diagnostics,
"scope": scope,
}
# Hook integrator alone needs the scope signal: project-scope
# deploys keep ``command`` paths repo-relative (#1394), user-scope
# deploys absolutize them (#1310 / #1354). Sibling integrators
# don't accept this kwarg, so include it only for hooks.
if _prim_name == "hooks":
_call_kwargs["user_scope"] = scope is InstallScope.USER
_int_result = getattr(_integrator, _entry.integrate_method)(
_target,
package_info,
project_root,
force=force,
managed_files=managed_files,
diagnostics=diagnostics,
scope=scope,
**_call_kwargs,
)
result["links_resolved"] += _int_result.links_resolved
for tp in _int_result.target_paths:
Expand Down
56 changes: 47 additions & 9 deletions src/apm_cli/integration/hook_integrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -447,11 +447,17 @@ def _rewrite_command_for_target(
else target_rel
)
new_command = new_command.replace(full_var, resolved_cmd)
elif deploy_root is not None:
# File absent: resolve to absolute source path so Claude Code
# gets a clear "file not found" rather than an unexpanded variable.
else:
# File absent: always warn so a misconfigured hook is never
# silently deployed. For user-scope (deploy_root set) also
# rewrite the unexpanded variable to an absolute source path
# so the target surfaces a clear "file not found". For
# project-scope (deploy_root is None) leave the variable in
# place -- rewriting to an absolute path would re-introduce
# the #1394 portability regression in committed configs.
_rich_warning(f"Hook script not found: {source_file}")
new_command = new_command.replace(full_var, str(source_file))
if deploy_root is not None:
new_command = new_command.replace(full_var, str(source_file))

# Handle relative ./path and .\path references (safe to run after
# ${CLAUDE_PLUGIN_ROOT} substitution since replacements produce paths
Expand Down Expand Up @@ -479,11 +485,12 @@ def _rewrite_command_for_target(
else target_rel
)
new_command = new_command.replace(rel_ref, resolved_cmd)
elif deploy_root is not None:
# File absent: resolve to absolute source path so the target
# gets a clear "file not found" rather than a bare relative ref.
else:
# File absent: always warn (see ${PLUGIN_ROOT} branch above
# for the project-scope vs user-scope rationale).
_rich_warning(f"Hook script not found: {source_file}")
new_command = new_command.replace(rel_ref, str(source_file))
if deploy_root is not None:
new_command = new_command.replace(rel_ref, str(source_file))

return new_command, scripts_to_copy

Expand Down Expand Up @@ -988,6 +995,7 @@ def _integrate_merged_hooks(
managed_files: set = None, # noqa: RUF013
diagnostics=None,
target=None,
user_scope: bool = False,
) -> HookIntegrationResult:
"""Integrate hooks by merging into a target-specific JSON config.

Expand All @@ -1009,6 +1017,19 @@ def _integrate_merged_hooks(
if config.require_dir and not target_dir.exists():
return _empty

# Absolutize hook commands only for user-scope deploys. Claude
# Code (and the Codex/Cursor/Gemini equivalents) reads
# ``~/.claude/settings.json`` without a fixed cwd and does not
# expand ``${CLAUDE_PLUGIN_ROOT}`` in that file (see #1310 / #1354),
# so user-scope deploys must write absolute paths. Project-scope
# ``<repo>/.claude/settings.json`` is typically checked in and runs
# with cwd at the repo root, where repo-relative paths resolve
# correctly -- baking absolute machine paths into checked-in config
# breaks portability across clones, contributors, and CI (#1394).
# ``user_scope`` is threaded from the caller's ``InstallScope`` so
# the gate is explicit rather than inferred from deploy-root shape.
_deploy_root_for_rewrite = project_root if user_scope else None

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:
Expand Down Expand Up @@ -1079,7 +1100,7 @@ def _integrate_merged_hooks(
config.target_key,
hook_file_dir=hook_file.parent,
root_dir=root_dir,
deploy_root=project_root,
deploy_root=_deploy_root_for_rewrite,
)

# Merge hooks into config (additive)
Expand Down Expand Up @@ -1283,6 +1304,8 @@ def integrate_package_hooks_claude(
force: bool = False,
managed_files: set = None, # noqa: RUF013
diagnostics=None,
*,
user_scope: bool = False,
) -> HookIntegrationResult:
"""Integrate hooks into .claude/settings.json.

Expand All @@ -1295,6 +1318,7 @@ def integrate_package_hooks_claude(
force=force,
managed_files=managed_files,
diagnostics=diagnostics,
user_scope=user_scope,
)

def integrate_package_hooks_cursor(
Expand All @@ -1304,6 +1328,8 @@ def integrate_package_hooks_cursor(
force: bool = False,
managed_files: set = None, # noqa: RUF013
diagnostics=None,
*,
user_scope: bool = False,
) -> HookIntegrationResult:
"""Integrate hooks into .cursor/hooks.json.

Expand All @@ -1316,6 +1342,7 @@ def integrate_package_hooks_cursor(
force=force,
managed_files=managed_files,
diagnostics=diagnostics,
user_scope=user_scope,
)

def integrate_package_hooks_codex(
Expand All @@ -1325,6 +1352,8 @@ def integrate_package_hooks_codex(
force: bool = False,
managed_files: set = None, # noqa: RUF013
diagnostics=None,
*,
user_scope: bool = False,
) -> HookIntegrationResult:
"""Integrate hooks into .codex/hooks.json.

Expand All @@ -1337,6 +1366,7 @@ def integrate_package_hooks_codex(
force=force,
managed_files=managed_files,
diagnostics=diagnostics,
user_scope=user_scope,
)

# ------------------------------------------------------------------
Expand All @@ -1353,12 +1383,19 @@ def integrate_hooks_for_target(
managed_files: set = None, # noqa: RUF013
diagnostics=None,
scope=None,
user_scope: bool = False,
) -> "HookIntegrationResult":
"""Integrate hooks for a single *target*.

Copilot uses individual JSON files (genuinely different pattern).
All other merge-based targets are dispatched via the
``_MERGE_HOOK_TARGETS`` registry.

``user_scope`` controls whether merged-hook ``command`` paths are
rewritten to absolute paths (required when deploying to
``~/.claude/settings.json`` -- see #1310 / #1354) or left
repo-relative so checked-in project-scope configs stay portable
across clones, contributors, and CI runners (#1394).
"""
if target.name == "copilot":
return self.integrate_package_hooks(
Expand All @@ -1380,6 +1417,7 @@ def integrate_hooks_for_target(
managed_files=managed_files,
diagnostics=diagnostics,
target=target,
user_scope=user_scope,
)

return HookIntegrationResult(
Expand Down
Loading
Loading