| 1 | #!/bin/bash |
| 2 | set -euo pipefail |
| 3 | |
| 4 | # Check last30days configuration status and show appropriate welcome message. |
| 5 | # Priority for this status hook (mirrors lib/env.py): |
| 6 | # process env > trusted .claude/last30days.env > ~/.config/last30days/.env > Keychain presence |
| 7 | # Project-scoped config is loaded only when LAST30DAYS_TRUST_PROJECT_CONFIG is |
| 8 | # truthy in the process environment or the global config file — never from the |
| 9 | # project file itself (it cannot self-grant trust). |
| 10 | |
| 11 | GLOBAL_ENV="$HOME/.config/last30days/.env" |
| 12 | if [[ "${LAST30DAYS_CONFIG_DIR+x}" == "x" ]]; then |
| 13 | if [[ -n "$LAST30DAYS_CONFIG_DIR" ]]; then |
| 14 | GLOBAL_ENV="$LAST30DAYS_CONFIG_DIR/.env" |
| 15 | else |
| 16 | GLOBAL_ENV="" |
| 17 | fi |
| 18 | fi |
| 19 | |
| 20 | # Ensure LAST30DAYS_MEMORY_DIR exists for HTML-brief / raw-markdown saves. |
| 21 | # SKILL.md and the engine default this via the same env-var fallback. Fresh |
| 22 | # installs otherwise fail silently on first --emit=html run. See #395. |
| 23 | mkdir -p "${LAST30DAYS_MEMORY_DIR:-$HOME/Documents/Last30Days}" 2>/dev/null || true |
| 24 | |
| 25 | # Helper: warn if file permissions are too open |
| 26 | check_perms() { |
| 27 | local file="$1" |
| 28 | if [[ ! -f "$file" ]]; then return; fi |
| 29 | # Git-for-Windows / MSYS / Cygwin run stat in noacl mode (always 644), |
| 30 | # so this POSIX check is a false positive. Windows perms use ACLs. |
| 31 | case "$(uname -s 2>/dev/null)" in |
| 32 | MINGW*|MSYS*|CYGWIN*) return ;; |
| 33 | esac |
| 34 | local perms |
| 35 | # Try GNU stat first (Linux), fall back to BSD stat (macOS). |
| 36 | # On Linux, `stat -f` prints filesystem info (not permissions) and exits 0, |
| 37 | # so the previous BSD-first ordering left $perms as multi-line garbage on |
| 38 | # every Linux session start and printed a false WARNING. |
| 39 | perms=$(stat -c '%a' "$file" 2>/dev/null || stat -f '%Lp' "$file" 2>/dev/null || echo "") |
| 40 | if [[ -n "$perms" && "$perms" != "600" && "$perms" != "400" ]]; then |
| 41 | chmod 600 "$file" && echo "/last30days: WARNING — $file had permissions $perms — auto-fixed with chmod 600" || echo "/last30days: WARNING — $file has permissions $perms (should be 600). Fix: chmod 600 $file" |
| 42 | fi |
| 43 | } |
| 44 | |
| 45 | trim_ws() { |
| 46 | local s="$1" |
| 47 | s="${s#"${s%%[![:space:]]*}"}" |
| 48 | s="${s%"${s##*[![:space:]]}"}" |
| 49 | printf '%s' "$s" |
| 50 | } |
| 51 | |
| 52 | strip_outer_quotes() { |
| 53 | local s="$1" |
| 54 | if [[ ${#s} -ge 2 ]]; then |
| 55 | if [[ "${s:0:1}" == '"' && "${s: -1}" == '"' ]]; then |
| 56 | s="${s:1:${#s}-2}" |
| 57 | elif [[ "${s:0:1}" == "'" && "${s: -1}" == "'" ]]; then |
| 58 | s="${s:1:${#s}-2}" |
| 59 | fi |
| 60 | fi |
| 61 | printf '%s' "$s" |
| 62 | } |
| 63 | |
| 64 | # Load env file into variables for inspection (without exporting) |
| 65 | load_env_vars() { |
| 66 | local file="$1" |
| 67 | if [[ -f "$file" ]]; then |
| 68 | while IFS='=' read -r key value; do |
| 69 | # Skip comments, empty lines |
| 70 | [[ "$key" =~ ^[[:space:]]*# ]] && continue |
| 71 | [[ -z "$key" ]] && continue |
| 72 | key="$(trim_ws "$key")" |
| 73 | # Only plain identifiers may reach `printf -v`. printf -v uses assignment |
| 74 | # semantics, so a key carrying an array subscript — e.g. `x[$(id)]` — has |
| 75 | # that subscript arithmetic-evaluated, which runs the command inside it. |
| 76 | # A project-scoped .claude/last30days.env is attacker-controlled as soon |
| 77 | # as an untrusted repo is opened, so an unvalidated key here is arbitrary |
| 78 | # code execution at session start. |
| 79 | [[ "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue |
| 80 | value="$(strip_outer_quotes "$(trim_ws "$value")")" |
| 81 | # Strip inline comments (# preceded by whitespace) to prevent |
| 82 | # command substitution in backtick-containing comments |
| 83 | value="${value%%[[:space:]]#*}" |
| 84 | if [[ -n "$key" && -n "$value" ]]; then |
| 85 | # printf -v writes via assignment semantics (global from inside a |
| 86 | # function), works on macOS's /bin/bash 3.2 — `declare -g` is 4.2+. |
| 87 | printf -v "ENV_${key}" '%s' "$value" |
| 88 | fi |
| 89 | done < "$file" |
| 90 | fi |
| 91 | } |
| 92 | |
| 93 | # Match lib/env.py::_truthy — process/global trust signal only. |
| 94 | is_truthy() { |
| 95 | local v |
| 96 | v="$(trim_ws "$1")" |
| 97 | case "$v" in |
| 98 | 1|[Tt][Rr][Uu][Ee]|[Yy][Ee][Ss]|[Oo][Nn]) return 0 ;; |
| 99 | *) return 1 ;; |
| 100 | esac |
| 101 | } |
| 102 | |
| 103 | # Project config cannot self-grant trust. Process env (including empty/0 deny) |
| 104 | # wins when set; otherwise the global config file's trust flag is consulted. |
| 105 | project_config_trusted() { |
| 106 | if [[ "${LAST30DAYS_TRUST_PROJECT_CONFIG+x}" == "x" ]]; then |
| 107 | is_truthy "$LAST30DAYS_TRUST_PROJECT_CONFIG" |
| 108 | return $? |
| 109 | fi |
| 110 | is_truthy "${ENV_LAST30DAYS_TRUST_PROJECT_CONFIG:-}" |
| 111 | } |
| 112 | |
| 113 | # Mirror lib/env.py::_find_project_env: walk up from $PWD for |
| 114 | # .claude/last30days.env, stopping at the git root, $HOME, or filesystem root. |
| 115 | # Prints the absolute path on stdout when found; returns 1 when none. |
| 116 | find_project_env() { |
| 117 | local dir candidate parent |
| 118 | dir="$PWD" |
| 119 | while :; do |
| 120 | candidate="${dir}/.claude/last30days.env" |
| 121 | if [[ -f "$candidate" ]]; then |
| 122 | printf '%s' "$candidate" |
| 123 | return 0 |
| 124 | fi |
| 125 | # Stop at git root even if no project env was found there (matches env.py). |
| 126 | if [[ -e "${dir}/.git" ]]; then |
| 127 | return 1 |
| 128 | fi |
| 129 | if [[ "$dir" == "$HOME" ]]; then |
| 130 | return 1 |
| 131 | fi |
| 132 | parent="$(dirname "$dir")" |
| 133 | if [[ "$parent" == "$dir" ]]; then |
| 134 | return 1 |
| 135 | fi |
| 136 | dir="$parent" |
| 137 | done |
| 138 | } |
| 139 | |
| 140 | # Determine which config file(s) are active. Always load global first (when |
| 141 | # present) so a trust signal there can unlock the project file — matching |
| 142 | # lib/env.py, where the project file is never parsed before the trust check. |
| 143 | CONFIG_FILE="" |
| 144 | if [[ -n "$GLOBAL_ENV" && -f "$GLOBAL_ENV" ]]; then |
| 145 | CONFIG_FILE="$GLOBAL_ENV" |
| 146 | check_perms "$GLOBAL_ENV" |
| 147 | load_env_vars "$GLOBAL_ENV" |
| 148 | fi |
| 149 | |
| 150 | PROJECT_ENV="" |
| 151 | if project_config_trusted; then |
| 152 | # `|| true` keeps set -e from aborting when no project env is in the walk. |
| 153 | PROJECT_ENV="$(find_project_env)" || true |
| 154 | fi |
| 155 | if [[ -n "$PROJECT_ENV" && -f "$PROJECT_ENV" ]]; then |
| 156 | CONFIG_FILE="$PROJECT_ENV" |
| 157 | check_perms "$PROJECT_ENV" |
| 158 | load_env_vars "$PROJECT_ENV" |
| 159 | fi |
| 160 | |
| 161 | # Load Keychain item presence for status checks without reading secret values. |
| 162 | # Runtime credential resolution still happens in lib/env.py; this hook only |
| 163 | # needs to avoid stale first-run/source-count messages. |
| 164 | load_keychain_presence() { |
| 165 | case "$(uname -s 2>/dev/null)" in |
| 166 | Darwin*) ;; |
| 167 | *) return 0 ;; |
| 168 | esac |
| 169 | command -v security >/dev/null 2>&1 || return 0 |
| 170 | |
| 171 | local user key env_var current |
| 172 | user="${USER:-}" |
| 173 | if [[ -z "$user" ]]; then |
| 174 | user="$(id -un 2>/dev/null || true)" |
| 175 | fi |
| 176 | [[ -n "$user" ]] || return 0 |
| 177 | |
| 178 | for key in SETUP_COMPLETE OPENAI_API_KEY SCRAPECREATORS_API_KEY AUTH_TOKEN CT0 XAI_API_KEY BSKY_HANDLE EXA_API_KEY; do |
| 179 | env_var="ENV_${key}" |
| 180 | current="${!env_var:-}" |
| 181 | if [[ -z "$current" ]]; then |
| 182 | current="${!key:-}" |
| 183 | fi |
| 184 | [[ -n "$current" ]] && continue |
| 185 | if security find-generic-password -a "$user" -s "last30days-${key}" >/dev/null 2>&1; then |
| 186 | printf -v "ENV_${key}" '%s' "keychain" |
| 187 | fi |
| 188 | done |
| 189 | return 0 |
| 190 | } |
| 191 | |
| 192 | load_keychain_presence |
| 193 | |
| 194 | # Check SETUP_COMPLETE (from file, env, or Keychain presence) |
| 195 | SETUP_COMPLETE="${ENV_SETUP_COMPLETE:-${SETUP_COMPLETE:-}}" |
| 196 | |
| 197 | # Compute last-run summary line (if last-run.json exists) |
| 198 | if [[ "${LAST30DAYS_CONFIG_DIR+x}" == "x" ]]; then |
| 199 | if [[ -n "$LAST30DAYS_CONFIG_DIR" ]]; then |
| 200 | LAST_RUN_FILE="$LAST30DAYS_CONFIG_DIR/last-run.json" |
| 201 | else |
| 202 | LAST_RUN_FILE="" |
| 203 | fi |
| 204 | else |
| 205 | LAST_RUN_FILE="$HOME/.config/last30days/last-run.json" |
| 206 | fi |
| 207 | LAST_RUN_LINE="" |
| 208 | # python3 -c, NOT a heredoc: bash 5.3 feeds heredocs to the child through a |
| 209 | # pipe and can deadlock in heredoc_write inside command substitution, hanging |
| 210 | # this hook forever at session start (observed on Homebrew bash 5.3.15). |
| 211 | if [[ -n "$LAST_RUN_FILE" && -f "$LAST_RUN_FILE" ]] && command -v python3 &>/dev/null; then |
| 212 | LAST_RUN_LINE=$(LAST_RUN_FILE="$LAST_RUN_FILE" python3 -c ' |
| 213 | import datetime |
| 214 | import json |
| 215 | import os |
| 216 | |
| 217 | path = os.environ["LAST_RUN_FILE"] |
| 218 | try: |
| 219 | with open(path) as fh: |
| 220 | d = json.load(fh) |
| 221 | topic = (d.get("topic") or "?")[:60] |
| 222 | ts = d.get("timestamp", "") |
| 223 | dt = datetime.datetime.fromisoformat(ts.replace("Z", "+00:00")) |
| 224 | delta = (datetime.datetime.now(datetime.timezone.utc) - dt).total_seconds() |
| 225 | if delta < 60: ago = f"{int(delta)}s ago" |
| 226 | elif delta < 3600: ago = f"{int(delta//60)}m ago" |
| 227 | elif delta < 86400: ago = f"{int(delta//3600)}h ago" |
| 228 | else: ago = f"{int(delta//86400)}d ago" |
| 229 | total = d.get("total", 0) |
| 230 | print(f" Last run: \"{topic}\" · {ago} · {total} results") |
| 231 | except Exception: |
| 232 | pass |
| 233 | ' 2>/dev/null || true) |
| 234 | fi |
| 235 | |
| 236 | # Detect capability that doesn't need a config file: yt-dlp on PATH. |
| 237 | # Done before the new-user early-exit so first-run users with yt-dlp |
| 238 | # installed see YouTube is already available. See #394. |
| 239 | HAS_YTDLP="" |
| 240 | if command -v yt-dlp &>/dev/null; then |
| 241 | HAS_YTDLP="yes" |
| 242 | fi |
| 243 | |
| 244 | # If setup has never been run, show welcome message for new users |
| 245 | if [[ -z "$SETUP_COMPLETE" && -z "$CONFIG_FILE" && -z "${ENV_OPENAI_API_KEY:-${OPENAI_API_KEY:-}}" && -z "${ENV_SCRAPECREATORS_API_KEY:-${SCRAPECREATORS_API_KEY:-}}" && -z "${ENV_AUTH_TOKEN:-${AUTH_TOKEN:-}}" && -z "${ENV_XAI_API_KEY:-${XAI_API_KEY:-}}" ]]; then |
| 246 | # printf, NOT cat-with-heredoc: see the bash 5.3 heredoc deadlock note above. |
| 247 | if [[ -n "$HAS_YTDLP" ]]; then |
| 248 | # YouTube is already working via the on-system yt-dlp binary — don't list |
| 249 | # it as something the wizard needs to unlock. See #394. |
| 250 | printf '%s\n' \ |
| 251 | '/last30days: Ready to use. Run /last30days to get started — setup takes 30 seconds.' \ |
| 252 | ' Research any topic across Reddit, HN, X, YouTube, Polymarket (last 30 days).' \ |
| 253 | '' \ |
| 254 | 'Reddit, Hacker News, Polymarket, and YouTube (yt-dlp detected) work out of the box.' \ |
| 255 | 'The setup wizard can unlock X/Twitter and more.' \ |
| 256 | ' Detected: yt-dlp is installed (YouTube transcripts ready, no setup needed).' |
| 257 | else |
| 258 | printf '%s\n' \ |
| 259 | '/last30days: Ready to use. Run /last30days to get started — setup takes 30 seconds.' \ |
| 260 | ' Research any topic across Reddit, HN, X, YouTube, Polymarket (last 30 days).' \ |
| 261 | '' \ |
| 262 | 'Reddit, Hacker News, and Polymarket work out of the box.' \ |
| 263 | 'The setup wizard can unlock X/Twitter, YouTube, and more.' |
| 264 | fi |
| 265 | if [[ -n "$LAST_RUN_LINE" ]]; then |
| 266 | echo "$LAST_RUN_LINE" |
| 267 | fi |
| 268 | exit 0 |
| 269 | fi |
| 270 | |
| 271 | # Setup done but check for ScrapeCreators |
| 272 | HAS_SCRAPECREATORS="${ENV_SCRAPECREATORS_API_KEY:-${SCRAPECREATORS_API_KEY:-}}" |
| 273 | HAS_X="" |
| 274 | if [[ -n "${ENV_AUTH_TOKEN:-${AUTH_TOKEN:-}}" && -n "${ENV_CT0:-${CT0:-}}" ]]; then |
| 275 | HAS_X="yes" |
| 276 | fi |
| 277 | HAS_XAI="${ENV_XAI_API_KEY:-${XAI_API_KEY:-}}" |
| 278 | HAS_BSKY="${ENV_BSKY_HANDLE:-${BSKY_HANDLE:-}}" |
| 279 | HAS_EXA="${ENV_EXA_API_KEY:-${EXA_API_KEY:-}}" |
| 280 | |
| 281 | # Count active sources |
| 282 | SOURCE_COUNT=2 # HN + Polymarket are always free |
| 283 | if [[ -n "$HAS_X" || -n "$HAS_XAI" ]]; then |
| 284 | SOURCE_COUNT=$((SOURCE_COUNT + 1)) |
| 285 | fi |
| 286 | # Reddit public JSON always works |
| 287 | SOURCE_COUNT=$((SOURCE_COUNT + 1)) |
| 288 | if [[ -n "$HAS_YTDLP" ]]; then |
| 289 | SOURCE_COUNT=$((SOURCE_COUNT + 1)) |
| 290 | fi |
| 291 | if [[ -n "$HAS_EXA" ]]; then |
| 292 | SOURCE_COUNT=$((SOURCE_COUNT + 1)) |
| 293 | fi |
| 294 | if [[ -n "$HAS_BSKY" ]]; then |
| 295 | SOURCE_COUNT=$((SOURCE_COUNT + 1)) |
| 296 | fi |
| 297 | if [[ -n "$HAS_SCRAPECREATORS" ]]; then |
| 298 | # Start with Reddit comments + TikTok + Instagram, subtract any in EXCLUDE_SOURCES. |
| 299 | # Normalise EXCLUDED by removing whitespace; case-insensitive matches below |
| 300 | # mirror pipeline.py's .strip().lower() parsing without requiring sed/tr. |
| 301 | SC_ADD=3 |
| 302 | EXCLUDED="${ENV_EXCLUDE_SOURCES:-${EXCLUDE_SOURCES:-}}" |
| 303 | EXCLUDED_NORM="${EXCLUDED//[[:space:]]/}" |
| 304 | if [[ ",$EXCLUDED_NORM," == *",[Tt][Ii][Kk][Tt][Oo][Kk],"* ]]; then |
| 305 | SC_ADD=$((SC_ADD - 1)) |
| 306 | fi |
| 307 | if [[ ",$EXCLUDED_NORM," == *",[Ii][Nn][Ss][Tt][Aa][Gg][Rr][Aa][Mm],"* ]]; then |
| 308 | SC_ADD=$((SC_ADD - 1)) |
| 309 | fi |
| 310 | SOURCE_COUNT=$((SOURCE_COUNT + SC_ADD)) |
| 311 | fi |
| 312 | |
| 313 | if [[ -n "$HAS_SCRAPECREATORS" ]]; then |
| 314 | # Fully configured — compact ready message |
| 315 | echo "/last30days: Ready — ${SOURCE_COUNT} sources active." |
| 316 | echo " Research any topic across social + market + web sources (last 30 days)." |
| 317 | if [[ -n "$LAST_RUN_LINE" ]]; then |
| 318 | echo "$LAST_RUN_LINE" |
| 319 | fi |
| 320 | else |
| 321 | # Setup done but missing ScrapeCreators — recommend it |
| 322 | echo "/last30days: Ready — ${SOURCE_COUNT} sources active." |
| 323 | echo " Research any topic across social + market + web sources (last 30 days)." |
| 324 | if [[ -n "$LAST_RUN_LINE" ]]; then |
| 325 | echo "$LAST_RUN_LINE" |
| 326 | fi |
| 327 | echo " Tip: Add ScrapeCreators for Reddit comments + TikTok + Instagram." |
| 328 | echo " 100 free credits, no credit card — scrapecreators.com" |
| 329 | echo " last30days has no affiliation with any API provider." |
| 330 | fi |
| 331 | |
| 332 | # The branches above end with `[[ -n "$LAST_RUN_LINE" ]] && echo ...`. When |
| 333 | # LAST_RUN_LINE is empty, that test returns 1 and is the script's last command, |
| 334 | # leaking exit=1 to callers (e.g. SessionStart hook drivers) despite no error. |
| 335 | exit 0 |
| 336 |