feat(apm): consume plugins through apm and keep the install fresh at SessionStart #98
7
.gitignore
vendored
7
.gitignore
vendored
@@ -33,3 +33,10 @@ apm_modules/
|
||||
# deployed copy would add a third mirror of the same skills to drift against.
|
||||
.claude/skills/
|
||||
.claude/agents/
|
||||
|
||||
# APM hook deployment output — `apm install` copies each package's referenced
|
||||
# hook scripts here and tracks its own settings.json entries in the sidecar.
|
||||
# Regenerated on every install; the authoring source is
|
||||
# plugins/<name>/.apm/hooks/ (ADR-0019).
|
||||
.claude/hooks/
|
||||
.claude/apm-hooks.json
|
||||
|
||||
@@ -37,8 +37,9 @@ Fall back to raw shell only when no skill covers it.
|
||||
|
||||
## Setup and testing
|
||||
|
||||
- Run `apm install` to deploy this repo's own skills and agents into `.claude/skills/` and `.claude/agents/`. Both are gitignored install output, not authoring source — `plugins/<name>/.apm/` remains the only place to edit. The six dependencies in root `apm.yml` resolve from the holocron **remote**, unpinned against the default branch, so a `.apm/` edit is not visible to the running session until it is pushed and `apm install` re-runs. Needs the network, and needs `apm_modules/` (which it materializes) left gitignored. `apm install` also configures the `obsidian` MCP server into the repo's `.mcp.json`, carried over from `plugins/bin/.mcp.json`.
|
||||
- Do not add repo-owned keys to `.claude/settings.json`. apm treats that file as its own deployed artifact: `apm audit --ci` replays the install into a scratch tree and diffs, so anything apm would not have written there — an `enabledPlugins` block, a real `hooks` entry — is permanent drift that fails the `apm-audit-ci` pre-push hook. The file's committed content is exactly `{"hooks": {}}`. Machine-specific settings go in the gitignored `.claude/settings.local.json`, which apm does not deploy and the replay does not compare; shared enforcement belongs in `.pre-commit-config.yaml`.
|
||||
- Run `apm install` to deploy this repo's own skills and agents into `.claude/skills/` and `.claude/agents/`. Both are gitignored install output, not authoring source — `plugins/<name>/.apm/` remains the only place to edit. The six dependencies in root `apm.yml` resolve from the holocron **remote**, unpinned against the default branch, so a `.apm/` edit is not visible to the running session until it is pushed and `apm update` re-runs (`apm install` deploys from `apm.lock.yaml` and does not re-resolve refs). Needs the network, and needs `apm_modules/` (which it materializes) left gitignored. `apm install` also configures the `obsidian` MCP server into the repo's `.mcp.json`, carried over from `plugins/bin/.mcp.json`.
|
||||
- Do not add repo-owned keys to `.claude/settings.json`. apm treats that file as its own deployed artifact: `apm audit --ci` replays the install into a scratch tree and diffs, so anything apm would not have written there — an `enabledPlugins` block, a real `hooks` entry — is permanent drift that fails the `apm-audit-ci` pre-push hook. Its committed content is whatever apm last wrote — `{"hooks": {}}` until kyberforge's `SessionStart` hook lands there, after which the merged hook entry is apm's output and belongs in the commit (ADR-0019). What does not change is that nothing repo-authored goes in the file. A hook you want in this repo is authored in `plugins/<name>/.apm/hooks/` and deployed by apm, never hand-written here. Machine-specific settings go in the gitignored `.claude/settings.local.json`, which apm does not deploy and the replay does not compare; shared enforcement belongs in `.pre-commit-config.yaml`.
|
||||
- Keeping the install current is automatic but not free. Because the six dependencies are unpinned, deployed skills go stale whenever anyone merges. kyberforge ships a `SessionStart` hook that runs `apm outdated` at startup (~0.7s) and, when something is behind, runs `apm update --yes` and asks the host to re-scan skills (~10.4s). That rewrites `apm.lock.yaml`, so an unexplained modification to it after opening a session is expected, not a bug — commit or discard it deliberately. Note `apm install` alone will **not** pick up remote changes; it deploys from the lock. `apm update` is the command that re-resolves refs.
|
||||
- Install git hooks via `pc-run`, wiring all three stages — this repo's `.pre-commit-config.yaml` has no `default_install_hook_types`, so a plain install silently skips `commit-msg` (Conventional Commits) and `pre-push` (the 13-hook gate described below).
|
||||
- Install the `apm` CLI — four pre-push hooks shell out to it: `apm-marketplace-check`, `apm-audit-ci`, `apm-pack-check-clean`, and `check-plugin-content-sync` (via `scripts/sync-plugin-content.sh`, which wraps `apm pack`). `apm-marketplace-check` and `apm-pack-check-clean` are bare `apm …` hook entries and `apm-audit-ci` is a `bash -c` loop calling `apm` once per package, so without it the push dies with an unhelpful "command not found". Use `apm-install`, or `curl -sSL https://aka.ms/apm-unix | sh`; verify with `apm --version`.
|
||||
- Install `jq` — required by `scripts/check-manifests.sh` and `scripts/sync-plugin-content.sh`, both pre-push. These at least fail loudly (`Error: jq is required but not installed`).
|
||||
|
||||
16
apm.yml
16
apm.yml
@@ -30,6 +30,22 @@ dependencies:
|
||||
path: plugins/lint
|
||||
mcp: []
|
||||
|
||||
# Turns apm's executable-trust gate ON. Without this block the gate is disabled
|
||||
# and every hook, bin and MCP primitive a dependency ships deploys silently —
|
||||
# verified: `apm approve --list` reports "Executable-trust gate disabled -- all
|
||||
# executables deploy" until an `executables:` block exists.
|
||||
#
|
||||
# kyberforge ships the SessionStart hook that keeps this install level with the
|
||||
# remote (ADR-0019). The key is version-pinned by apm's own design, so a
|
||||
# kyberforge version bump makes this entry stop matching and the hook stops
|
||||
# deploying until the version here is bumped too. If skills silently go stale
|
||||
# after a kyberforge release, check this first.
|
||||
executables:
|
||||
allow:
|
||||
kyberforge#1.4.1:
|
||||
hooks: true
|
||||
bin: true
|
||||
|
||||
marketplace:
|
||||
# apm's Claude marketplace mapper only emits description:/version: into the
|
||||
# compiled marketplace.json when set explicitly here (an override) — the
|
||||
|
||||
@@ -61,7 +61,9 @@ was stale the moment the switch landed; `AGENTS.md` and `CONTEXT.md` are updated
|
||||
form still resolves for anyone installing holocron natively, so skill bodies written for both
|
||||
audiences should name the bare skill.
|
||||
|
||||
**apm owns `.claude/settings.json`.** `apm audit --ci` replays the install into a scratch tree and
|
||||
**apm owns `.claude/settings.json`.** (ADR-0019 supersedes the "exactly `{"hooks": {}}`" claim
|
||||
below — once a package ships a hook, apm merges it into that file and the merged entry is apm's own
|
||||
output. The rule that nothing repo-authored goes in the file is unchanged.) `apm audit --ci` replays the install into a scratch tree and
|
||||
diffs it against the worktree. apm's hook integrator writes that file, so the replay expects
|
||||
exactly what apm would have written — `{"hooks": {}}` — and any repo-owned key in it is permanent
|
||||
drift that fails the `apm-audit-ci` pre-push hook. Verified both directions: with the pre-existing
|
||||
@@ -93,9 +95,16 @@ apm's "contributed no entries to claude settings; skipped" warning on `kyberforg
|
||||
accurate and harmless.
|
||||
|
||||
**A `.apm/` edit now needs a round trip.** The dependency resolves from the remote, so an edit is
|
||||
invisible to the running session until it is pushed and `apm install` re-runs. Under the native
|
||||
install with `autoUpdate` the shape was the same; it is more noticeable now because `apm install`
|
||||
is a manual step where marketplace auto-update was not.
|
||||
invisible to the running session until it is pushed and the install is refreshed. Under the native
|
||||
install with `autoUpdate` the shape was the same; it was more noticeable here at first because the
|
||||
refresh is a manual step where marketplace auto-update was not — ADR-0019 automates it at
|
||||
`SessionStart`.
|
||||
|
||||
**Correction (2026-08-14): the refresh command is `apm update`, not `apm install`.** An earlier
|
||||
revision of this paragraph named `apm install`, which is wrong: `apm install` deploys from the
|
||||
pinned `resolved_commit` in `apm.lock.yaml` and does not re-resolve refs (`apm install --force`
|
||||
documents this explicitly — "does NOT refresh refs; use 'apm update' for that"). Running it after a
|
||||
merge redeploys the same content and reports success.
|
||||
|
||||
**User scope is untouched, deliberately.** `bin@holocron`, `gitea@holocron`, and a stale
|
||||
`hello-world@holocron` remain natively installed at user scope, and every project other than this
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# A SessionStart hook keeps the apm install current, replacing a git hook that never ran
|
||||
|
||||
ADR-0018 switched this repo to consuming its own plugins through `apm install`, with the six
|
||||
packages declared as unpinned git refs against the holocron remote's default branch. That decision
|
||||
left a hole it named but did not fill: the deployed content goes stale the moment anyone merges,
|
||||
and nothing detects it.
|
||||
|
||||
**Status: accepted (2026-08-14).**
|
||||
|
||||
## Context
|
||||
|
||||
The pre-existing answer was `scripts/git-hooks/post-push`, which pulled the marketplace clone and
|
||||
ran `claude plugin update kyberforge`. Issue #78 filed it as a bug — the hook updated `kyberforge`
|
||||
but not `gitea`, so gitea skills stayed pinned at a pre-refactor version after #67 merged.
|
||||
|
||||
The issue's premise was wrong in a way nobody had noticed for six weeks. **Git has no client-side
|
||||
`post-push` hook.** `githooks(5)` does not list one, and git 2.39.5 does not invoke one.
|
||||
`scripts/install.sh` copies every file in `scripts/git-hooks/` into `.git/hooks/`, so
|
||||
`.git/hooks/post-push` existed on disk and looked installed. It had never fired. The hook did not
|
||||
skip `gitea`; it skipped everything. Both tests that appeared to cover it — `test-post-push.sh` and
|
||||
`test-git-hooks-install.sh` — asserted only that the script behaved correctly when invoked directly
|
||||
and that install.sh copied the file. Neither asserted that git ever runs it.
|
||||
|
||||
That also makes the original framing wrong. Refreshing on push assumes the person who pushes is the
|
||||
person who goes stale, which is backwards: your install goes stale when *someone else* merges, and a
|
||||
push of your own is neither necessary nor sufficient for it to have happened.
|
||||
|
||||
## Decision
|
||||
|
||||
A `SessionStart` hook, shipped in `plugins/kyberforge/.apm/hooks/`, checks whether the install is
|
||||
behind and refreshes it in place.
|
||||
|
||||
`SessionStart` is the correct trigger because the thing that goes stale is the skill content a
|
||||
*session* loads, and that is the moment the staleness does damage. It also enables two things a git
|
||||
hook structurally cannot do: `additionalContext` puts the notice into the agent's context rather
|
||||
than terminal scrollback nobody reads, and `reloadSkills: true` makes the host re-scan the skill
|
||||
directories after the hook returns, so a refresh lands in the running session without a restart.
|
||||
|
||||
apm's own lifecycle events (`pre-/post-install`, `pre-/post-update`, `pre-/post-uninstall`) were
|
||||
rejected: they fire around apm operations already chosen, so they can announce a refresh but never
|
||||
detect that one is needed.
|
||||
|
||||
Three sub-decisions:
|
||||
|
||||
- **Refresh automatically rather than report.** The hook runs `apm update --yes` and asks for a skill
|
||||
reload. The rejected alternative was to report and let a human run it. Auto-refresh costs a
|
||||
rewritten `apm.lock.yaml` — a committed file — appearing as an unexplained modification in the
|
||||
working tree, on any branch, at any time. The emitted notice says so explicitly for that reason.
|
||||
- **`plugins/kyberforge/.apm/hooks/`, not `.claude/settings.json`.** ADR-0018 established that apm
|
||||
owns `.claude/settings.json` and that any repo-authored key in it is permanent `apm audit --ci`
|
||||
drift. A hook shipped in a package is written into that file by apm itself, so it is apm's output
|
||||
and does not drift. `.claude/settings.local.json` also works but is gitignored and machine-local,
|
||||
which fails the requirement that this travel with the repo.
|
||||
- **`startup` matcher only.** `resume`, `clear`, `compact` and `fork` would re-run the check on every
|
||||
compaction, and a compaction is not an event after which the remote can have moved.
|
||||
|
||||
The executable-trust gate is switched on at the same time. Root `apm.yml` gains an `executables:`
|
||||
block allowing kyberforge's hooks and bin.
|
||||
|
||||
## Consequences
|
||||
|
||||
**The gate is off until something turns it on, and this repo had it off.** `apm approve --list`
|
||||
reports `Executable-trust gate disabled -- all executables deploy` until an `executables:` block
|
||||
exists in `apm.yml`. Any hook, bin, or MCP primitive a dependency shipped would have deployed with
|
||||
no prompt and no record. The block added here closes that for this repo; every other apm project on
|
||||
this machine still has it open.
|
||||
|
||||
**The allow key is version-pinned, and that is a live failure mode.** apm writes
|
||||
`kyberforge#1.4.1`, not `kyberforge`. A kyberforge version bump makes the entry stop matching, the
|
||||
gate blocks the hook, and the install silently stops refreshing — the exact failure this ADR exists
|
||||
to end, reintroduced through the mechanism meant to secure it. The `executables:` block carries a
|
||||
comment saying to check it first when skills go stale after a release.
|
||||
|
||||
**A referenced hook script must be addressed at its `.apm/` path.** apm resolves
|
||||
`${CLAUDE_PLUGIN_ROOT}/...` against the installed package root, and `apm pack` keeps only `*.json`
|
||||
from `.apm/hooks/` when it builds the flat mirror. So `${CLAUDE_PLUGIN_ROOT}/hooks/check-apm-current.sh`
|
||||
resolves to the mirror, where the script does not exist — verified, apm reports
|
||||
`Hook script not found` and deploys a hook pointing at nothing. The working reference is
|
||||
`${CLAUDE_PLUGIN_ROOT}/.apm/hooks/check-apm-current.sh`. The script cannot simply be placed in
|
||||
`plugins/kyberforge/hooks/` either: that directory is `rm -rf`'d by every content sync (ADR-0017).
|
||||
A test pins the reference.
|
||||
|
||||
**Session startup gets slower when the install is stale.** Measured: ~0.7 s for the `apm outdated`
|
||||
check when everything is current, ~10.4 s when six packages are behind and the refresh runs. The
|
||||
hook declares `timeout: 320` to cover a cold multi-package fetch.
|
||||
|
||||
**The hook cannot install itself.** Dependencies resolve from the remote, so the hook does not
|
||||
deploy until this change is merged and `apm update` has run once against the new default branch.
|
||||
Until then the repo has the mechanism in source and not in effect.
|
||||
|
||||
**`.claude/settings.json` stops being `{"hooks": {}}`.** apm merges the hook into it and tracks
|
||||
ownership in a `.claude/apm-hooks.json` sidecar, with the script copied to
|
||||
`.claude/hooks/<pkg>/`. The sidecar and the script directory are gitignored install output; the
|
||||
settings file remains committed, now with apm-generated content in it. ADR-0018's statement that the
|
||||
committed content is exactly `{"hooks": {}}` is superseded on that point only — the rule it was
|
||||
protecting, that nothing repo-authored goes in that file, is unchanged.
|
||||
|
||||
**Native consumers are protected by a guard, not by the gate.** A host installing holocron through
|
||||
`claude plugin install` auto-discovers `hooks/hooks.json` and does not consult apm's trust gate at
|
||||
all. The script therefore exits silently when there is no `apm.lock.yaml` in the working directory,
|
||||
which is what makes it inert in a repo that does not consume packages through apm. Copilot CLI sees
|
||||
no hook at all, for the reasons already documented in `plugins/kyberforge/docs/hooks.md`.
|
||||
|
||||
**`scripts/git-hooks/` is now empty.** `post-push` and `test-post-push.sh` are deleted.
|
||||
`install.sh`'s copy block is generic and is kept; `test-git-hooks-install.sh` now synthesizes its
|
||||
own fixture hook instead of depending on a real one existing, so the mechanism stays tested and can
|
||||
be used again if a hook git actually invokes is ever wanted.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **A `post-merge` git hook.** Real, unlike `post-push`, and verified to fire on both a
|
||||
fast-forward `git pull` and a `git pull --rebase`. Rejected as the primary mechanism because a
|
||||
pull is the wrong signal, and because it cannot reload skills in a running session. It remains
|
||||
the only option for a project that consumes apm packages without a Claude-family host.
|
||||
- **Reporting instead of refreshing.** See the sub-decision above.
|
||||
- **A seventh plugin holding only this hook**, to avoid shipping it to external kyberforge
|
||||
consumers. Rejected as disproportionate: the `apm.lock.yaml` guard already makes the hook inert
|
||||
for anyone not consuming through apm, and a package exists to be maintained, versioned, and
|
||||
registered in the marketplace.
|
||||
@@ -63,4 +63,4 @@ This repo also has a `CLAUDE.md` at its root — the Claude Code entry point for
|
||||
|
||||
## Architectural decisions
|
||||
|
||||
Key hard-to-reverse decisions are recorded as ADRs in `docs/adr/`. There is no index file — the directory holds 17 numbered ADRs whose filenames state their decision, so `ls docs/adr/` is the index. Read a superseding ADR before the one it supersedes: ADR-0015 (apm as the authoring source of truth) supersedes ADR-0001 and moots ADR-0006, and ADR-0017 corrects ADR-0015's host-discovery gap. Entry points for the structure described on this page: ADR-0002 (two-tier CLAUDE.md), ADR-0003 (AGENTS.md as the provider-agnostic entry point), ADR-0015 and ADR-0017 (the two compilers behind the plugin roots).
|
||||
Key hard-to-reverse decisions are recorded as ADRs in `docs/adr/`. There is no index file — the directory holds 19 numbered ADRs whose filenames state their decision, so `ls docs/adr/` is the index. Read a superseding ADR before the one it supersedes: ADR-0015 (apm as the authoring source of truth) supersedes ADR-0001 and moots ADR-0006, ADR-0017 corrects ADR-0015's host-discovery gap, and ADR-0019 supersedes one claim in ADR-0018 (that `.claude/settings.json`'s committed content is exactly `{"hooks": {}}`) while keeping the rule behind it. Entry points for the structure described on this page: ADR-0002 (two-tier CLAUDE.md), ADR-0003 (AGENTS.md as the provider-agnostic entry point), ADR-0015 and ADR-0017 (the two compilers behind the plugin roots).
|
||||
|
||||
42
plugins/kyberforge/.apm/hooks/check-apm-current.sh
Executable file
42
plugins/kyberforge/.apm/hooks/check-apm-current.sh
Executable file
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
# SessionStart: keep an apm-consumed install level with its remote.
|
||||
#
|
||||
# Packages declared as unpinned git refs resolve against the remote default
|
||||
# branch, so the deployed .claude/skills/ and .claude/agents/ go stale the
|
||||
# moment anyone merges. The staleness bites when a session loads skills, which
|
||||
# is why this runs at SessionStart rather than off a git hook — a pull is
|
||||
# neither necessary nor sufficient for the install to have drifted.
|
||||
#
|
||||
# Refreshes in place and asks the host to re-scan, so the running session picks
|
||||
# the new content up without a restart.
|
||||
#
|
||||
# Inert in any project that does not consume packages through apm.
|
||||
set -uo pipefail
|
||||
|
||||
# No lockfile means nothing was installed through apm here — e.g. a host that
|
||||
# installed this plugin natively. Say nothing and cost nothing.
|
||||
[[ -f apm.lock.yaml ]] || exit 0
|
||||
command -v apm > /dev/null 2>&1 || exit 0
|
||||
|
||||
# `apm outdated` exits 0 whether or not anything is stale, so the answer has to
|
||||
# come from its output. ~0.7s against six remote refs; a hung remote must not
|
||||
# hold the session open.
|
||||
outdated_output="$(timeout 60 apm outdated 2>&1)" || exit 0
|
||||
grep -q "outdated dependencies found" <<< "$outdated_output" || exit 0
|
||||
|
||||
stale_count="$(grep -oE '[0-9]+ outdated dependencies found' <<< "$outdated_output" | grep -oE '^[0-9]+' || true)"
|
||||
[[ "$stale_count" =~ ^[0-9]+$ ]] || stale_count="some"
|
||||
|
||||
# Only ever emit fixed text plus a digit-checked count — never interpolate
|
||||
# command output into the JSON, which would need escaping this cannot do safely.
|
||||
emit() {
|
||||
printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","reloadSkills":%s,"additionalContext":"%s"}}\n' "$1" "$2"
|
||||
}
|
||||
|
||||
if timeout 300 apm update --yes > /dev/null 2>&1; then
|
||||
emit true "apm install was ${stale_count} package(s) behind the remote default branch and has been refreshed automatically; skills and agents were redeployed and re-scanned. apm.lock.yaml has been rewritten and is now a modified file in the working tree - commit it or discard it deliberately."
|
||||
else
|
||||
emit false "apm install is ${stale_count} package(s) behind the remote default branch and the automatic refresh failed. Deployed skills and agents may be stale. Run: apm update --yes"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -1,3 +1,16 @@
|
||||
{
|
||||
"hooks": {}
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/.apm/hooks/check-apm-current.sh",
|
||||
"timeout": 320,
|
||||
"type": "command"
|
||||
}
|
||||
],
|
||||
"matcher": "startup"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,16 +34,58 @@ mirrored file; the `check-plugin-content-sync` pre-push hook reports it as drift
|
||||
}
|
||||
```
|
||||
|
||||
Events (**partial list**): `PreToolUse`, `PostToolUse`, `Notification`, `Stop`. Claude Code's plugin
|
||||
hook set is larger — `SessionStart`, `SessionEnd`, `UserPromptSubmit`, `PreCompact` and
|
||||
`SubagentStop` also exist — and this repo's vendored corpus does not enumerate it anywhere:
|
||||
`docs/research/docs/claude-code-plugins/configuration.md:100` describes the file as "Event handlers
|
||||
(PreToolUse, PostToolUse, etc.)", and `agent-definition.md:53` covers only the per-agent `hooks`
|
||||
field, not the plugin-level set. Treat the four names above as the ones this repo has verified, not
|
||||
as the schema. Check Claude Code's own hooks documentation before wiring an event not listed here.
|
||||
Events (**partial list**): `PreToolUse`, `PostToolUse`, `Notification`, `Stop`, and `SessionStart`
|
||||
(verified end-to-end by the hook below). Claude Code's plugin hook set is larger — `SessionEnd`,
|
||||
`UserPromptSubmit`, `PreCompact` and `SubagentStop` also exist — and this repo's vendored corpus does
|
||||
not enumerate it anywhere: `docs/research/docs/claude-code-plugins/configuration.md:100` describes
|
||||
the file as "Event handlers (PreToolUse, PostToolUse, etc.)", and `agent-definition.md:53` covers
|
||||
only the per-agent `hooks` field, not the plugin-level set. Treat the five names above as the ones
|
||||
this repo has verified, not as the schema. Check Claude Code's own hooks documentation before wiring
|
||||
an event not listed here.
|
||||
|
||||
Use `${CLAUDE_PLUGIN_ROOT}` to reference scripts inside this plugin — the plugin runs from a cache
|
||||
path after install, not its original repo location.
|
||||
## Referencing a script — use the `.apm/` path, not the mirror
|
||||
|
||||
Use `${CLAUDE_PLUGIN_ROOT}` to reference scripts inside this plugin; the plugin runs from a cache or
|
||||
`apm_modules/` path after install, not its original repo location. **Address the script at its
|
||||
`.apm/` path:**
|
||||
|
||||
```json
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/.apm/hooks/check-apm-current.sh"
|
||||
```
|
||||
|
||||
The obvious-looking `${CLAUDE_PLUGIN_ROOT}/hooks/check-apm-current.sh` does not work, and fails
|
||||
quietly enough to be worth spelling out. apm resolves the placeholder against the installed package
|
||||
root, where `hooks/` is the **generated mirror** — and `apm pack` merges only `*.json` out of
|
||||
`.apm/hooks/`, dropping every non-JSON file. So the mirror contains `hooks.json` and nothing else.
|
||||
apm prints `Hook script not found: .../hooks/check-apm-current.sh` and then deploys the hook anyway,
|
||||
pointing at a path with no file behind it.
|
||||
|
||||
Nor can the script be hand-placed in `plugins/kyberforge/hooks/` to satisfy that path:
|
||||
`sync-plugin-content.sh` runs `rm -rf` on the directory before every rebuild (ADR-0017), so it would
|
||||
be deleted on the next sync with no drift warning — the same trap that ate this document's
|
||||
predecessor.
|
||||
|
||||
`tests/test-apm-current-hook.sh` pins the reference so a well-meaning "simplification" back to
|
||||
`hooks/` fails the suite rather than silently disabling the hook.
|
||||
|
||||
## Deployed shape
|
||||
|
||||
At install, apm merges the event bindings into `.claude/settings.json`, copies the referenced script
|
||||
to `.claude/hooks/<pkg>/` (preserving its executable bit, preserving the `.apm/hooks/` subpath), and
|
||||
rewrites `command` to a `${CLAUDE_PROJECT_DIR}`-relative path. Ownership of its own entries is
|
||||
tracked in a `.claude/apm-hooks.json` sidecar, so an uninstall removes them without touching
|
||||
hand-authored hooks. Both `.claude/hooks/` and the sidecar are gitignored install output.
|
||||
|
||||
Note that apm's **executable-trust gate is off** unless the consuming project's `apm.yml` has an
|
||||
`executables:` block — without one, package hooks deploy with no prompt. See ADR-0019.
|
||||
|
||||
## The SessionStart hook
|
||||
|
||||
`check-apm-current.sh` keeps an apm-consumed install level with its remote: it runs `apm outdated`,
|
||||
and if anything is behind, runs `apm update --yes` and returns `reloadSkills: true` so the running
|
||||
session picks up the redeployed content. It exits silently when there is no `apm.lock.yaml` in the
|
||||
working directory, which makes it inert for any host that installed this plugin natively rather than
|
||||
through apm. Rationale, measurements, and the failure modes are in ADR-0019.
|
||||
|
||||
## GitHub Copilot CLI
|
||||
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
{
|
||||
"hooks": {}
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/.apm/hooks/check-apm-current.sh",
|
||||
"timeout": 320,
|
||||
"type": "command"
|
||||
}
|
||||
],
|
||||
"matcher": "startup"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Refresh the kyberforge plugin cache after every push
|
||||
set -euo pipefail
|
||||
|
||||
echo "→ Refreshing holocron marketplace clone..."
|
||||
if git -C ~/.claude/plugins/marketplaces/holocron pull --quiet; then
|
||||
echo "→ Updating kyberforge plugin cache..."
|
||||
if claude plugin update kyberforge; then
|
||||
echo "✓ kyberforge cache updated"
|
||||
else
|
||||
echo "⚠ claude plugin update kyberforge failed — run it manually" >&2
|
||||
fi
|
||||
else
|
||||
echo "⚠ Failed to pull holocron marketplace clone — run 'git -C ~/.claude/plugins/marketplaces/holocron pull' manually" >&2
|
||||
fi
|
||||
|
||||
exit 0
|
||||
159
tests/test-apm-current-hook.sh
Executable file
159
tests/test-apm-current-hook.sh
Executable file
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tests for plugins/kyberforge/.apm/hooks/check-apm-current.sh — the SessionStart
|
||||
# hook that keeps an apm-consumed install level with its remote.
|
||||
#
|
||||
# `apm` is mocked throughout: the hook's contract is "read `apm outdated`, decide,
|
||||
# emit SessionStart JSON", and that is testable without a network or a real
|
||||
# install.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
HOOK="$REPO_ROOT/plugins/kyberforge/.apm/hooks/check-apm-current.sh"
|
||||
HOOKS_JSON="$REPO_ROOT/plugins/kyberforge/.apm/hooks/hooks.json"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
pass() { echo " PASS: $1"; PASS=$((PASS + 1)); }
|
||||
fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); }
|
||||
|
||||
command -v python3 > /dev/null 2>&1 || { echo "python3 required"; exit 77; }
|
||||
|
||||
FAKE_BIN="$(mktemp -d)"
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$FAKE_BIN" "$WORK"' EXIT
|
||||
|
||||
# Mock `apm`. $1 chooses what `apm outdated` reports; $2 the exit code of
|
||||
# `apm update`. A sentinel file records whether update was actually invoked.
|
||||
make_apm() {
|
||||
local outdated_line="$1" update_exit="$2"
|
||||
cat > "$FAKE_BIN/apm" << EOF
|
||||
#!/usr/bin/env bash
|
||||
case "\$1" in
|
||||
outdated) echo "$outdated_line"; exit 0 ;;
|
||||
update) touch "$WORK/update-was-called"; exit $update_exit ;;
|
||||
esac
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$FAKE_BIN/apm"
|
||||
}
|
||||
|
||||
run_hook() { (cd "$WORK" && PATH="$FAKE_BIN:$PATH" bash "$HOOK" 2>/dev/null); }
|
||||
|
||||
json_field() { python3 -c 'import json,sys; print(json.load(sys.stdin)["hookSpecificOutput"][sys.argv[1]])' "$1"; }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
echo "--- inert without an apm-consumed install ---"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
make_apm "[!] 6 outdated dependencies found" 0
|
||||
rm -f "$WORK/apm.lock.yaml" "$WORK/update-was-called"
|
||||
out="$(run_hook)"; rc=$?
|
||||
[[ $rc -eq 0 ]] && pass "exits 0 with no apm.lock.yaml" || fail "should exit 0 with no apm.lock.yaml"
|
||||
[[ -z "$out" ]] && pass "emits nothing with no apm.lock.yaml" || fail "should stay silent with no apm.lock.yaml"
|
||||
[[ ! -f "$WORK/update-was-called" ]] && pass "does not run apm update with no apm.lock.yaml" \
|
||||
|| fail "must not touch a project that does not use apm"
|
||||
|
||||
# A native (non-apm) install of this plugin hits exactly this path, so it is the
|
||||
# guard that keeps the hook from acting on someone else's repo.
|
||||
touch "$WORK/apm.lock.yaml"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "--- inert when apm is absent ---"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
rm -f "$WORK/update-was-called"
|
||||
out="$( (cd "$WORK" && PATH="$(dirname "$(command -v bash)")" bash "$HOOK" 2>/dev/null) )"; rc=$?
|
||||
[[ $rc -eq 0 ]] && pass "exits 0 when apm is not on PATH" || fail "should exit 0 when apm is missing"
|
||||
[[ -z "$out" ]] && pass "emits nothing when apm is not on PATH" || fail "should stay silent when apm is missing"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "--- install already current ---"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
make_apm "[*] All dependencies are up-to-date" 0
|
||||
rm -f "$WORK/update-was-called"
|
||||
out="$(run_hook)"; rc=$?
|
||||
[[ $rc -eq 0 ]] && pass "exits 0 when current" || fail "should exit 0 when current"
|
||||
[[ -z "$out" ]] && pass "emits nothing when current" || fail "should stay silent when current"
|
||||
[[ ! -f "$WORK/update-was-called" ]] && pass "does not run apm update when current" \
|
||||
|| fail "must not update when nothing is stale"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "--- stale, refresh succeeds ---"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
make_apm "[!] 6 outdated dependencies found" 0
|
||||
rm -f "$WORK/update-was-called"
|
||||
out="$(run_hook)"; rc=$?
|
||||
[[ $rc -eq 0 ]] && pass "exits 0 when stale" || fail "should exit 0 when stale"
|
||||
[[ -f "$WORK/update-was-called" ]] && pass "runs apm update when stale" || fail "should run apm update when stale"
|
||||
if echo "$out" | python3 -m json.tool > /dev/null 2>&1; then
|
||||
pass "emits valid JSON"
|
||||
[[ "$(echo "$out" | json_field hookEventName)" == "SessionStart" ]] \
|
||||
&& pass "declares hookEventName SessionStart" || fail "wrong hookEventName"
|
||||
[[ "$(echo "$out" | json_field reloadSkills)" == "True" ]] \
|
||||
&& pass "asks the host to reload skills after a successful refresh" || fail "reloadSkills should be true"
|
||||
echo "$out" | json_field additionalContext | grep -q "6 package" \
|
||||
&& pass "reports the stale package count" || fail "should report the count"
|
||||
# The lockfile rewrite is the surprising part of auto-updating; the notice has
|
||||
# to say so or a dirty worktree looks like something else went wrong.
|
||||
echo "$out" | json_field additionalContext | grep -q "apm.lock.yaml" \
|
||||
&& pass "warns that apm.lock.yaml was rewritten" || fail "should warn about the lockfile rewrite"
|
||||
else
|
||||
fail "emits valid JSON"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "--- stale, refresh fails ---"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
make_apm "[!] 3 outdated dependencies found" 1
|
||||
out="$(run_hook)"; rc=$?
|
||||
[[ $rc -eq 0 ]] && pass "exits 0 when the refresh fails" || fail "must never fail the session start"
|
||||
if echo "$out" | python3 -m json.tool > /dev/null 2>&1; then
|
||||
pass "emits valid JSON on failure"
|
||||
[[ "$(echo "$out" | json_field reloadSkills)" == "False" ]] \
|
||||
&& pass "does not ask for a skill reload when nothing was deployed" || fail "reloadSkills should be false"
|
||||
echo "$out" | json_field additionalContext | grep -q "apm update" \
|
||||
&& pass "tells the reader how to refresh by hand" || fail "should name the manual command"
|
||||
else
|
||||
fail "emits valid JSON on failure"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "--- unparseable count degrades instead of breaking the JSON ---"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
make_apm "[!] lots of outdated dependencies found" 0
|
||||
out="$(run_hook)"
|
||||
echo "$out" | python3 -m json.tool > /dev/null 2>&1 \
|
||||
&& pass "still emits valid JSON when the count cannot be parsed" || fail "JSON broke on an unparseable count"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "--- hooks.json wiring ---"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# apm resolves script paths relative to the package root, and `apm pack` keeps
|
||||
# only *.json from .apm/hooks/ — so a ${CLAUDE_PLUGIN_ROOT}/hooks/... reference
|
||||
# points at a directory the script never reaches. It must be .apm/-relative.
|
||||
referenced="$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(d["hooks"]["SessionStart"][0]["hooks"][0]["command"])' "$HOOKS_JSON")"
|
||||
[[ "$referenced" == '${CLAUDE_PLUGIN_ROOT}/.apm/hooks/check-apm-current.sh' ]] \
|
||||
&& pass "hooks.json references the script at its .apm/ path" \
|
||||
|| fail "hooks.json references '$referenced' — must be \${CLAUDE_PLUGIN_ROOT}/.apm/hooks/check-apm-current.sh"
|
||||
|
||||
[[ -x "$HOOK" ]] && pass "hook script is executable" || fail "hook script must be executable"
|
||||
|
||||
matcher="$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(d["hooks"]["SessionStart"][0]["matcher"])' "$HOOKS_JSON")"
|
||||
[[ "$matcher" == "startup" ]] && pass "fires on startup only" \
|
||||
|| fail "matcher is '$matcher' — resume/clear/compact would re-run this every compaction"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
[[ $FAIL -eq 0 ]]
|
||||
@@ -40,11 +40,25 @@ DEPLOY_EXECUTABLES=()
|
||||
DEPLOY_DIRS=()
|
||||
EOF
|
||||
|
||||
# Copy the real install.sh and git-hooks into the temp repo
|
||||
# Copy the real install.sh into the temp repo.
|
||||
cp "$REPO_ROOT/scripts/install.sh" "$TEMP_REPO/scripts/install.sh"
|
||||
cp -r "$REPO_ROOT/scripts/git-hooks" "$TEMP_REPO/scripts/git-hooks"
|
||||
|
||||
HOOKS_SRC="$TEMP_REPO/scripts/git-hooks"
|
||||
mkdir -p "$HOOKS_SRC"
|
||||
|
||||
# Copy whatever real hooks exist, then add a synthetic fixture. The repo
|
||||
# currently ships none — the only entry was `post-push`, removed once it was
|
||||
# found that git has no such client-side hook, so it had never fired (see
|
||||
# ADR-0019). install.sh's copy block is generic and stays worth testing, so the
|
||||
# fixture keeps that coverage alive independently of whether any real hook
|
||||
# happens to exist. Real hooks are still picked up by the loops below.
|
||||
if [[ -d "$REPO_ROOT/scripts/git-hooks" ]]; then
|
||||
find "$REPO_ROOT/scripts/git-hooks" -maxdepth 1 -type f -exec cp {} "$HOOKS_SRC/" \;
|
||||
fi
|
||||
|
||||
FIXTURE_HOOK="fixture-hook"
|
||||
printf '#!/usr/bin/env bash\nexit 0\n' > "$HOOKS_SRC/$FIXTURE_HOOK"
|
||||
chmod +x "$HOOKS_SRC/$FIXTURE_HOOK"
|
||||
|
||||
run_install() {
|
||||
HOME="$TEMP_HOME" bash "$TEMP_REPO/scripts/install.sh" > /dev/null 2>&1
|
||||
@@ -108,7 +122,7 @@ OTHER_REPO="$(mktemp -d)"
|
||||
git -C "$OTHER_REPO" init -q
|
||||
if HOME="$TEMP_HOME" GIT_DIR="$OTHER_REPO/.git" GIT_WORK_TREE="$OTHER_REPO" \
|
||||
bash "$TEMP_REPO/scripts/install.sh" > /dev/null 2>&1; then
|
||||
if [[ -f "$TEMP_REPO/.git/hooks/post-push" ]]; then
|
||||
if [[ -f "$TEMP_REPO/.git/hooks/$FIXTURE_HOOK" ]]; then
|
||||
pass "resolves \$TEMP_REPO/.git/hooks/ even with inherited GIT_DIR"
|
||||
else
|
||||
fail "installed into inherited GIT_DIR instead of \$TEMP_REPO"
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
pass() { echo " PASS: $1"; PASS=$((PASS + 1)); }
|
||||
fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); }
|
||||
|
||||
HOOK="$REPO_ROOT/scripts/git-hooks/post-push"
|
||||
FAKE_BIN="$(mktemp -d)"
|
||||
FAKE_HOME="$(mktemp -d)"
|
||||
trap 'rm -rf "$FAKE_BIN" "$FAKE_HOME"' EXIT
|
||||
|
||||
# Helper: write a fake git stub that exits with the given code
|
||||
make_git() {
|
||||
local exit_code="$1"
|
||||
printf '#!/usr/bin/env bash\nexit %s\n' "$exit_code" > "$FAKE_BIN/git"
|
||||
chmod +x "$FAKE_BIN/git"
|
||||
}
|
||||
|
||||
# Helper: write a fake claude stub; optionally touches a sentinel file on invocation
|
||||
make_claude() {
|
||||
local exit_code="$1"
|
||||
local log="${2:-}"
|
||||
if [[ -n "$log" ]]; then
|
||||
printf '#!/usr/bin/env bash\ntouch "%s"\nexit %s\n' "$log" "$exit_code" > "$FAKE_BIN/claude"
|
||||
else
|
||||
printf '#!/usr/bin/env bash\nexit %s\n' "$exit_code" > "$FAKE_BIN/claude"
|
||||
fi
|
||||
chmod +x "$FAKE_BIN/claude"
|
||||
}
|
||||
|
||||
# Run the hook with mocked PATH and HOME; suppress all output
|
||||
run_hook() {
|
||||
PATH="$FAKE_BIN:$PATH" HOME="$FAKE_HOME" bash "$HOOK" > /dev/null 2>&1
|
||||
}
|
||||
|
||||
# Run the hook and capture combined stdout+stderr
|
||||
capture_hook() {
|
||||
PATH="$FAKE_BIN:$PATH" HOME="$FAKE_HOME" bash "$HOOK" 2>&1 || true
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
echo "--- post-push: always exits 0 ---"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
make_git 1; make_claude 0
|
||||
if run_hook; then
|
||||
pass "exits 0 when git pull fails"
|
||||
else
|
||||
fail "should exit 0 when git pull fails"
|
||||
fi
|
||||
|
||||
make_git 0; make_claude 1
|
||||
if run_hook; then
|
||||
pass "exits 0 when claude plugin update fails"
|
||||
else
|
||||
fail "should exit 0 when claude plugin update fails"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "--- post-push: success output ---"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
make_git 0; make_claude 0
|
||||
output=$(capture_hook)
|
||||
echo "$output" | grep -q "kyberforge cache updated" \
|
||||
&& pass "prints success message when both git pull and claude succeed" \
|
||||
|| fail "should print success message when both commands succeed"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "--- post-push: warnings on failure ---"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
make_git 1; make_claude 0
|
||||
output=$(capture_hook)
|
||||
echo "$output" | grep -qi "failed to pull" \
|
||||
&& pass "warns when git pull fails" \
|
||||
|| fail "should warn when git pull fails"
|
||||
|
||||
make_git 0; make_claude 1
|
||||
output=$(capture_hook)
|
||||
echo "$output" | grep -qi "failed" \
|
||||
&& pass "warns when claude plugin update fails" \
|
||||
|| fail "should warn when claude plugin update fails"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "--- post-push: claude not called when git pull fails ---"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CLAUDE_LOG="$FAKE_HOME/claude-was-called"
|
||||
make_git 1; make_claude 0 "$CLAUDE_LOG"
|
||||
run_hook
|
||||
if [[ ! -f "$CLAUDE_LOG" ]]; then
|
||||
pass "claude not called when git pull fails"
|
||||
else
|
||||
fail "claude should not be called when git pull fails"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
[[ $FAIL -eq 0 ]]
|
||||
Reference in New Issue
Block a user