Files
wehub-resource-sync c3749daf48
Tests / test-linux (3.13) (push) Failing after 0s
Tests / test-linux (3.11) (push) Failing after 1s
Tests / lint (push) Failing after 0s
Tests / test-linux (3.9) (push) Failing after 1s
Docker / build (push) Failing after 1s
Docker / build-gpu (push) Failing after 2s
Tests / test-windows (push) Has been cancelled
Tests / test-macos (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:03:03 +08:00

400 lines
14 KiB
Bash
Executable File

#!/bin/bash
# MEMPALACE CURSOR HOOK INSTALLER
#
# Optional helper. Copies the three Cursor hook scripts to a
# stable install location and merges entries into a Cursor
# `hooks.json` config file — without clobbering unrelated hooks
# already in that file.
#
# This is NEVER auto-invoked. Editor config is sacred; we do not
# modify a user's hooks.json without explicit consent. The user runs
# this script (or wires the hooks manually) as a documented opt-in.
#
# === USAGE ===
#
# hooks/cursor/install.sh [options]
#
# Options:
# --scope user|project Target scope. Default: user.
# - user: merges into ~/.cursor/hooks.json
# - project: merges into <target>/.cursor/hooks.json
# --target <path> Project root for --scope project (default: $PWD).
# Ignored for --scope user.
# --install-dir <path> Where to copy the hook scripts.
# Default: ~/.mempalace/hooks/cursor
# --variant full|minimal Which hook set to wire.
# - full: stop + preCompact + sessionStart
# - minimal: stop only
# Default: full.
# --dry-run Print the would-be JSON to stdout, do not write
# and do not copy scripts.
# --uninstall Remove MemPalace entries from the target
# hooks.json (preserves unrelated hooks).
# Does NOT delete the installed scripts.
# -h, --help Show this help and exit.
#
# === PORTABILITY ===
#
# Pure bash 3.2 + POSIX tools + python3 (which the hook scripts
# themselves already require). No `jq` dependency.
#
# Python helpers are materialised to temp files rather than piped via
# `$(... <<'PYEOF' ... PYEOF)` to dodge the bash 3.2.57 parser bug
# that trips on parens nested inside a heredoc body that lives inside
# a `$(...)` command substitution.
set -e
usage() {
sed -n '2,38p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
}
# ── Defaults ──────────────────────────────────────────────────────
SCOPE="user"
TARGET=""
INSTALL_DIR="$HOME/.mempalace/hooks/cursor"
VARIANT="full"
DRY_RUN=0
UNINSTALL=0
# ── Parse args ────────────────────────────────────────────────────
while [ $# -gt 0 ]; do
case "$1" in
--scope)
shift
SCOPE="${1:-}"
;;
--target)
shift
TARGET="${1:-}"
;;
--install-dir)
shift
INSTALL_DIR="${1:-}"
;;
--variant)
shift
VARIANT="${1:-}"
;;
--dry-run) DRY_RUN=1 ;;
--uninstall) UNINSTALL=1 ;;
-h|--help)
usage
exit 0
;;
*)
printf 'install.sh: unknown argument: %s\n' "$1" >&2
usage >&2
exit 64
;;
esac
shift || true
done
case "$SCOPE" in
user|project) ;;
*)
printf 'install.sh: --scope must be "user" or "project" (got "%s")\n' \
"$SCOPE" >&2
exit 64
;;
esac
case "$VARIANT" in
full|minimal) ;;
*)
printf 'install.sh: --variant must be "full" or "minimal" (got "%s")\n' \
"$VARIANT" >&2
exit 64
;;
esac
# ── Resolve paths ─────────────────────────────────────────────────
_script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)"
SOURCE_DIR="$_script_dir"
# Resolve --install-dir to an absolute path before it gets baked into
# hooks.json. Cursor invokes hook commands from its own working
# directory (typically the project root), so a relative command path
# would silently fail to launch the hook. gh-PR review caught this.
case "$INSTALL_DIR" in
/*) ;;
*) INSTALL_DIR="$PWD/$INSTALL_DIR" ;;
esac
# Resolve the Python interpreter the same way the hooks themselves do
# so a user with a non-default Python is consistent across install +
# runtime.
if [ -n "${MEMPAL_PYTHON:-}" ] && [ -x "$MEMPAL_PYTHON" ]; then
PYTHON_BIN="$MEMPAL_PYTHON"
else
PYTHON_BIN="$(command -v python3 2>/dev/null || true)"
fi
if [ -z "$PYTHON_BIN" ]; then
printf 'install.sh: python3 not found on PATH; cannot proceed.\n' >&2
printf 'Set $MEMPAL_PYTHON to an interpreter path or install python3.\n' >&2
exit 1
fi
# Determine the target hooks.json path.
case "$SCOPE" in
user)
if [ -n "$TARGET" ]; then
printf 'install.sh: --target is only meaningful with --scope project; ignoring.\n' >&2
fi
TARGET_DIR="$HOME/.cursor"
;;
project)
TARGET_DIR="${TARGET:-$PWD}/.cursor"
;;
esac
TARGET_FILE="$TARGET_DIR/hooks.json"
# Determine which commands the merge / uninstall logic should
# install or remove. Paths point at the install location, NOT the
# source repo — once the user runs install.sh they can move / delete
# the cloned repo without breaking the wiring.
SAVE_CMD="$INSTALL_DIR/mempal_save_hook_cursor.sh"
PRECOMPACT_CMD="$INSTALL_DIR/mempal_precompact_hook_cursor.sh"
WAKE_CMD="$INSTALL_DIR/mempal_wake_hook_cursor.sh"
# ── Step 1: copy scripts (skipped on --dry-run / --uninstall) ─────
if [ "$UNINSTALL" -eq 0 ] && [ "$DRY_RUN" -eq 0 ]; then
mkdir -p "$INSTALL_DIR/lib"
cp "$SOURCE_DIR/lib/common.sh" "$INSTALL_DIR/lib/common.sh"
cp "$SOURCE_DIR/mempal_save_hook_cursor.sh" "$INSTALL_DIR/"
cp "$SOURCE_DIR/mempal_precompact_hook_cursor.sh" "$INSTALL_DIR/"
cp "$SOURCE_DIR/mempal_wake_hook_cursor.sh" "$INSTALL_DIR/"
chmod +x "$INSTALL_DIR/mempal_save_hook_cursor.sh" \
"$INSTALL_DIR/mempal_precompact_hook_cursor.sh" \
"$INSTALL_DIR/mempal_wake_hook_cursor.sh"
printf 'install.sh: copied scripts to %s\n' "$INSTALL_DIR" >&2
fi
# ── Short-circuit: uninstall with no existing file is a no-op ─────
#
# Without this, the merge step would happily write an empty
# {"version": 1, "hooks": {}} to a brand new file that the user
# never asked us to create — surprising behaviour that the test
# suite explicitly guards against.
if [ "$UNINSTALL" -eq 1 ] && [ ! -f "$TARGET_FILE" ]; then
printf 'install.sh: nothing to uninstall (%s does not exist)\n' \
"$TARGET_FILE" >&2
exit 0
fi
# ── Step 2: merge / unmerge hooks.json via python3 ────────────────
#
# Materialise the merge logic to a temp .py file (see bash 3.2
# rationale at the top of this script), then invoke it. The Python
# script is responsible for:
# * tolerating a missing or empty hooks.json (starts from {})
# * preserving unrelated hook entries on install
# * preserving unrelated hook entries on uninstall
# * recognising MemPalace entries by basename in the `command` field
# * idempotent install (re-running does not duplicate entries)
# mktemp portability: pass an explicit absolute template so we sidestep
# the BSD vs GNU difference in `-t` semantics (BSD treats it as a
# prefix; GNU treats it as a template). Honour TMPDIR if set, fall
# back to /tmp. gh-PR review caught the previous `-t` form as
# non-portable.
MERGE_PY="$(mktemp "${TMPDIR:-/tmp}/mempal-install-merge.XXXXXX")"
trap 'rm -f "$MERGE_PY"' EXIT
cat > "$MERGE_PY" <<'PYEOF'
"""hooks.json merge helper for hooks/cursor/install.sh.
Argv:
sys.argv[1]: path to hooks.json (may not exist)
sys.argv[2]: variant ("full" or "minimal")
sys.argv[3]: uninstall flag ("1" or "0")
sys.argv[4]: save_cmd absolute path
sys.argv[5]: precompact_cmd absolute path
sys.argv[6]: wake_cmd absolute path
Output: prints the merged JSON to stdout. Exits 2 on a malformed
existing config (refuses to overwrite a broken file).
"""
import json
import os
import sys
target_file = sys.argv[1]
variant = sys.argv[2]
uninstall = sys.argv[3] == "1"
save_cmd = sys.argv[4]
precompact_cmd = sys.argv[5]
wake_cmd = sys.argv[6]
# Recognise our entries by basename. The three filenames below are
# the unique product-of-our-naming convention; any entry whose command
# ends in one of them is treated as a MemPalace entry on
# install (so we replace rather than duplicate it) and on uninstall
# (so we remove it without touching unrelated entries). Matching on
# basename rather than a full-path substring lets users pick any
# --install-dir without breaking uninstall.
MEMPAL_BASENAMES = (
"mempal_save_hook_cursor.sh",
"mempal_precompact_hook_cursor.sh",
"mempal_wake_hook_cursor.sh",
)
if os.path.exists(target_file):
with open(target_file, "r", encoding="utf-8") as fh:
try:
cfg = json.load(fh)
except Exception as exc:
sys.stderr.write(
"install.sh: existing %s is not valid JSON: %s\n"
"Refusing to overwrite. Fix the file and retry.\n"
% (target_file, exc)
)
sys.exit(2)
else:
cfg = {}
if not isinstance(cfg, dict):
sys.stderr.write(
"install.sh: %s top level must be a JSON object; got %s\n"
% (target_file, type(cfg).__name__)
)
sys.exit(2)
cfg.setdefault("version", 1)
cfg.setdefault("hooks", {})
if not isinstance(cfg["hooks"], dict):
sys.stderr.write(
"install.sh: %s 'hooks' must be a JSON object\n" % target_file
)
sys.exit(2)
def is_mempal_entry(entry):
if not isinstance(entry, dict):
return False
cmd = entry.get("command", "")
if not isinstance(cmd, str):
return False
# Match on basename so a customised --install-dir (e.g. /opt/...,
# ~/.local/share/..., or anything with or without a leading dot)
# still round-trips through uninstall.
base = os.path.basename(cmd)
return base in MEMPAL_BASENAMES
def filter_mempal(entries):
if not isinstance(entries, list):
return entries
return [e for e in entries if not is_mempal_entry(e)]
def upsert(event, entry):
existing = cfg["hooks"].get(event, [])
if not isinstance(existing, list):
sys.stderr.write(
"install.sh: %s hooks[%s] must be a list\n" % (target_file, event)
)
sys.exit(2)
cleaned = [e for e in existing if not is_mempal_entry(e)]
cleaned.append(entry)
cfg["hooks"][event] = cleaned
if uninstall:
for event in list(cfg["hooks"].keys()):
cfg["hooks"][event] = filter_mempal(cfg["hooks"][event])
if not cfg["hooks"][event]:
del cfg["hooks"][event]
else:
upsert("stop", {"command": save_cmd, "loop_limit": 1})
if variant == "full":
upsert("preCompact", {"command": precompact_cmd})
upsert("sessionStart", {"command": wake_cmd})
# Stable key order for the events MemPalace touches, then preserve
# any unrelated event names in their original order so future Cursor
# events we don't know about yet still round-trip.
known_order = [
"sessionStart",
"stop",
"preCompact",
"sessionEnd",
"preToolUse",
"postToolUse",
"postToolUseFailure",
"subagentStart",
"subagentStop",
"beforeShellExecution",
"afterShellExecution",
"beforeMCPExecution",
"afterMCPExecution",
"beforeReadFile",
"afterFileEdit",
"beforeSubmitPrompt",
"afterAgentResponse",
"afterAgentThought",
"beforeTabFileRead",
"afterTabFileEdit",
"workspaceOpen",
]
ordered_hooks = {}
for event in known_order:
if event in cfg["hooks"]:
ordered_hooks[event] = cfg["hooks"][event]
for event, entries in cfg["hooks"].items():
if event not in ordered_hooks:
ordered_hooks[event] = entries
cfg["hooks"] = ordered_hooks
print(json.dumps(cfg, indent=2, sort_keys=False))
PYEOF
NEW_JSON="$("$PYTHON_BIN" "$MERGE_PY" \
"$TARGET_FILE" "$VARIANT" "$UNINSTALL" \
"$SAVE_CMD" "$PRECOMPACT_CMD" "$WAKE_CMD")"
# ── Step 3: emit, write, or remove ────────────────────────────────
if [ "$DRY_RUN" -eq 1 ]; then
printf 'install.sh: --dry-run; would write to %s\n' "$TARGET_FILE" >&2
printf '%s\n' "$NEW_JSON"
exit 0
fi
mkdir -p "$TARGET_DIR"
# If --uninstall left an empty hooks object AND no other top-level
# keys beyond version, remove the file entirely so the user's
# `.cursor/` directory does not accumulate orphan configs.
if [ "$UNINSTALL" -eq 1 ]; then
# Inline the emptiness check via `python -c '...'` rather than a
# temp .py file. The body is short enough that a tmpfile is pure
# overhead, and removing the tmpfile eliminates a small leak
# window if the script is interrupted between mktemp and rm -f.
# gh-PR review suggested this simplification.
NON_EMPTY="$(printf '%s' "$NEW_JSON" | "$PYTHON_BIN" -c '
import json, sys
cfg = json.load(sys.stdin)
hooks = cfg.get("hooks", {})
extras = [k for k in cfg.keys() if k not in ("version", "hooks")]
print("1" if (hooks or extras) else "0")
')"
if [ "$NON_EMPTY" = "0" ] && [ -f "$TARGET_FILE" ]; then
rm -f "$TARGET_FILE"
printf 'install.sh: removed empty %s\n' "$TARGET_FILE" >&2
exit 0
fi
fi
TMP_FILE="${TARGET_FILE}.tmp.$$"
printf '%s\n' "$NEW_JSON" > "$TMP_FILE"
mv "$TMP_FILE" "$TARGET_FILE"
if [ "$UNINSTALL" -eq 1 ]; then
printf 'install.sh: removed MemPalace entries from %s\n' "$TARGET_FILE" >&2
else
printf 'install.sh: wrote %s\n' "$TARGET_FILE" >&2
printf 'install.sh: restart Cursor (or wait for it to reload hooks.json)\n' >&2
fi