From ac2fe4ad881b3312f4df36c506ae50c700c2f4b2 Mon Sep 17 00:00:00 2001 From: StuBehan Date: Thu, 30 Apr 2026 22:42:19 +0200 Subject: [PATCH 1/2] fix(#11): make Python <3.10 install failure visible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Could not find Python ≥ 3.10" exit message used to scroll past behind ~120 lines of UserNotifications Swift deprecation warnings emitted during the build step, so anyone tailing the install output just saw a non-zero exit with no actionable hint. - Redirect build.sh stdout+stderr to /tmp/stack-nudge-install-build.log; on real build failure, dump the last 20 lines so genuine errors stay visible while routine warnings stop drowning the transcript. - Surround the Python error with blank lines + a ✗ marker, and spell out both fix paths (brew install vs STACKNUDGE_PYTHON env var). - Document the Python ≥ 3.10 prerequisite at the top of the README's Install section so the requirement is visible before the user runs install.sh. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 ++ install.sh | 68 +++++++++++++++++++++++++++++++++++++++++------------- 2 files changed, 54 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index bad7c49..7efdbc7 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ ## Install +**Prerequisites:** Python ≥ 3.10 (the bundled voice engine [stackvox](https://github.com/StackOneHQ/stackvox) requires it). macOS ships 3.9 by default — install a newer one with `brew install python@3.13`, or set `STACKNUDGE_PYTHON=/path/to/python3` to point at one explicitly. + ```bash git clone https://github.com/StackOneHQ/stack-nudge.git cd stack-nudge diff --git a/install.sh b/install.sh index 0b5e617..e56eefb 100755 --- a/install.sh +++ b/install.sh @@ -11,11 +11,21 @@ echo "Installing stack-nudge..." mkdir -p "$INSTALL_DIR" -# Build and install the native app bundle (single persistent binary) +# Build and install the native app bundle (single persistent binary). +# build.sh's output (stdout + stderr — Swift emits ~120 lines of UserNotifications +# deprecation warnings on every build) is redirected to a log so the install +# transcript stays scannable. On real build failure the log's last 20 lines +# are dumped so the actual error doesn't get hidden. +BUILD_LOG="/tmp/stack-nudge-install-build.log" if [[ "$(uname -s)" == "Darwin" ]]; then echo "" echo "Building stack-nudge.app..." - bash "$SCRIPT_DIR/build.sh" >/dev/null + if ! bash "$SCRIPT_DIR/build.sh" > "$BUILD_LOG" 2>&1; then + echo "" + echo " ✗ Build failed. Last 20 lines of $BUILD_LOG:" + tail -20 "$BUILD_LOG" | sed 's/^/ /' + exit 1 + fi rm -rf "$HOME/Applications/stack-nudge.app" rm -rf "$HOME/Applications/stack-nudge-panel.app" # clean up old panel binary cp -r "$SCRIPT_DIR/build/stack-nudge.app" "$HOME/Applications/stack-nudge.app" @@ -51,8 +61,13 @@ echo "Setting up voice engine..." STACKVOX_SPEC="stackvox>=0.4.0" PYTHON=$(find_python) if [[ -z "$PYTHON" ]]; then - echo " Could not find Python ≥ 3.10. Install one (e.g. 'brew install python@3.13')" - echo " or set STACKNUDGE_PYTHON=/path/to/python3 and re-run." + echo "" + echo " ✗ Could not find Python ≥ 3.10 — required by the bundled voice engine (stackvox)." + echo "" + echo " Install one of these and re-run ./install.sh:" + echo " brew install python@3.13" + echo " STACKNUDGE_PYTHON=/path/to/python3 ./install.sh" + echo "" exit 1 fi if [[ ! -x "$VENV/bin/stackvox" ]]; then @@ -160,7 +175,7 @@ if [[ -d "$HOME/.claude" ]]; then echo "" echo "Detected Claude Code (~/.claude)" python3 - "$HOME/.claude/settings.json" "$NOTIFY" <<'PY' -import json, os, sys +import json, os, re, sys from pathlib import Path path = Path(sys.argv[1]) @@ -171,20 +186,37 @@ if path.exists(): else: settings = {} +# Match any prior stack-nudge / tinynudge hook entry regardless of where it +# was installed (legacy ~/.tinynudge, moved checkouts, etc.) so upgrades +# replace the stale entry instead of appending a duplicate that points at a +# dead path. +STALE = re.compile(r"(?:^|/)\.?(?:tinynudge|stack-nudge)/notify\.sh(?:\s|$)") + hooks = settings.setdefault("hooks", {}) # Permission hook blocks on a FIFO until the user approves via stack-nudge, # so it needs a longer timeout than Claude Code's 600s default. for event, arg, timeout in [("Stop", "stop", 30), ("PermissionRequest", "permission", 600)]: groups = hooks.setdefault(event, []) + + # Drop any existing group whose inner hook commands all look like ours + # (or are entirely empty after pruning ours). Mixed groups keep the + # non-stack-nudge entries intact. + cleaned = [] + for g in groups: + inner = g.get("hooks", []) + kept = [h for h in inner if not STALE.search(h.get("command", "") or "")] + if not kept: + continue + if kept != inner: + g = {**g, "hooks": kept} + cleaned.append(g) + groups[:] = cleaned + cmd = f"{notify} claude-code {arg}" - if not any( - any(h.get("command") == cmd for h in g.get("hooks", [])) - for g in groups - ): - groups.append({ - "matcher": "", - "hooks": [{"type": "command", "command": cmd, "timeout": timeout}], - }) + groups.append({ + "matcher": "", + "hooks": [{"type": "command", "command": cmd, "timeout": timeout}], + }) path.write_text(json.dumps(settings, indent=2) + "\n") print(f" Updated {path}") @@ -197,7 +229,7 @@ if [[ -d "$HOME/.cursor" ]]; then echo "" echo "Detected Cursor (~/.cursor)" python3 - "$HOME/.cursor/hooks.json" "$NOTIFY" <<'PY' -import json, sys +import json, re, sys from pathlib import Path path = Path(sys.argv[1]) @@ -205,11 +237,15 @@ notify = sys.argv[2] path.parent.mkdir(parents=True, exist_ok=True) settings = json.loads(path.read_text()) if path.exists() else {} +# Match any prior stack-nudge / tinynudge entry regardless of install path +# so upgrades replace stale entries rather than appending duplicates. +STALE = re.compile(r"(?:^|/)\.?(?:tinynudge|stack-nudge)/notify\.sh(?:\s|$)") + hooks = settings.setdefault("hooks", {}) stop_cmd = f"{notify} cursor stop" stop = hooks.setdefault("stop", []) -if not any(h.get("command") == stop_cmd for h in stop): - stop.append({"type": "command", "command": stop_cmd}) +stop[:] = [h for h in stop if not STALE.search(h.get("command", "") or "")] +stop.append({"type": "command", "command": stop_cmd}) path.write_text(json.dumps(settings, indent=2) + "\n") print(f" Updated {path}") From 379d5404b70f1a0e02b4434d2bef06d8639e9de7 Mon Sep 17 00:00:00 2001 From: StuBehan Date: Thu, 30 Apr 2026 22:42:28 +0200 Subject: [PATCH 2/2] fix(#12): replace stale stack-nudge hook entries on upgrade Prior installs at different paths (legacy ~/.tinynudge/notify.sh, moved checkouts, etc.) used to leave duplicate hook entries pointing at dead paths, because the dedupe check matched the current $NOTIFY path exactly. Match any path inside a tinynudge or stack-nudge directory using (?:^|/)\.?(?:tinynudge|stack-nudge)/notify\.sh(?:\s|$) which catches ~/.tinynudge, ~/.stack-nudge, /dev/checkout/stack-nudge, etc. while leaving unrelated hooks (other tools, mixed groups) intact. Applied symmetrically in install.sh (both Claude and Cursor blocks) and uninstall.sh so upgrades and removals are both path-agnostic. Co-Authored-By: Claude Opus 4.7 (1M context) --- uninstall.sh | 37 +++++++++++++++++++++---------------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/uninstall.sh b/uninstall.sh index 4b29f16..fcb6ce9 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -4,26 +4,32 @@ set -e INSTALL_DIR="${HOME}/.stack-nudge" -NOTIFY="$INSTALL_DIR/notify.sh" echo "Uninstalling stack-nudge..." -# Remove hooks from Claude Code +# Remove hooks from Claude Code. Matches anything inside a `tinynudge` or +# `stack-nudge` install dir so legacy entries (and dev checkouts pointing at +# moved paths) get cleaned up too, not just the current $NOTIFY path. if [[ -f "$HOME/.claude/settings.json" ]]; then - python3 - "$HOME/.claude/settings.json" "$NOTIFY" <<'PY' -import json, sys + python3 - "$HOME/.claude/settings.json" <<'PY' +import json, re, sys from pathlib import Path path = Path(sys.argv[1]) -notify = sys.argv[2] -prefix = f"{notify} claude-code" +STALE = re.compile(r"(?:^|/)\.?(?:tinynudge|stack-nudge)/notify\.sh(?:\s|$)") settings = json.loads(path.read_text()) hooks = settings.get("hooks", {}) for event in list(hooks.keys()): - hooks[event] = [ - g for g in hooks[event] - if not all((h.get("command") or "").startswith(prefix) for h in g.get("hooks", [])) - ] + cleaned = [] + for g in hooks[event]: + inner = g.get("hooks", []) + kept = [h for h in inner if not STALE.search(h.get("command", "") or "")] + if not kept: + continue + if kept != inner: + g = {**g, "hooks": kept} + cleaned.append(g) + hooks[event] = cleaned if not hooks[event]: del hooks[event] if not hooks: @@ -33,19 +39,18 @@ print(f" Cleaned {path}") PY fi -# Remove hooks from Cursor +# Remove hooks from Cursor. Same path-agnostic match as the Claude block. if [[ -f "$HOME/.cursor/hooks.json" ]]; then - python3 - "$HOME/.cursor/hooks.json" "$NOTIFY" <<'PY' -import json, sys + python3 - "$HOME/.cursor/hooks.json" <<'PY' +import json, re, sys from pathlib import Path path = Path(sys.argv[1]) -notify = sys.argv[2] -prefix = f"{notify} cursor" +STALE = re.compile(r"(?:^|/)\.?(?:tinynudge|stack-nudge)/notify\.sh(?:\s|$)") settings = json.loads(path.read_text()) hooks = settings.get("hooks", {}) for event in list(hooks.keys()): - hooks[event] = [h for h in hooks[event] if not (h.get("command") or "").startswith(prefix)] + hooks[event] = [h for h in hooks[event] if not STALE.search(h.get("command", "") or "")] if not hooks[event]: del hooks[event] if not hooks: