返回 last30days-skill
check-config.sh
根目录 / hooks / scripts / check-config.sh
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
336 lines BASH