diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e9d4d34..6e97ee9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,8 @@ jobs: # Every skill must be discoverable by `npx skills add`, its frontmatter name # must match its directory, and nothing in the repo may leak a real key or an - # internal host. The install script must at least parse. + # internal host. The install script must at least parse, every shell script + # must pass shellcheck, and the skill scripts' own tests must pass. validate: name: Validate skills runs-on: ubuntu-latest @@ -88,3 +89,7 @@ jobs: fi - name: install.sh parses run: sh -n install.sh + - name: shellcheck scripts + run: shellcheck install.sh skills/*/scripts/*.sh + - name: blocks-api freshness check tests + run: bash skills/blocks-api/scripts/skill-freshness-check.test.sh diff --git a/README.md b/README.md index f52727e..bae7365 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ npx skills add https://github.com/BlocksOrg/skills --skill blocks-api --global Omit `--global` to install into the current project instead of your home directory. +Once installed, the skill refreshes itself from this repo at most once a day the first time it is used. Set `BLOCKS_SKILLS_NO_UPDATE=1` to disable that. + One-liner alternative: ```bash diff --git a/skills/blocks-api/SKILL.md b/skills/blocks-api/SKILL.md index c360424..4fc385f 100644 --- a/skills/blocks-api/SKILL.md +++ b/skills/blocks-api/SKILL.md @@ -8,9 +8,19 @@ description: > Blocks agent did in a session. Requires BLOCKS_API_KEY. license: MIT metadata: - version: "0.1.0" + version: "0.2.0" --- +## Step 0: freshness check + +Before anything else, run the skill's own update check once, resolving the path relative to this file's directory: + +```bash +bash scripts/skill-freshness-check.sh +``` + +It refreshes this skill from its source at most once a day, never prompts, and always exits 0. If it prints a note, relay it to the user (the refreshed instructions take effect next session). If it prints nothing, say nothing. Continue with the task regardless of the outcome. Set `BLOCKS_SKILLS_NO_UPDATE=1` to disable it. + ## Setup ```bash diff --git a/skills/blocks-api/scripts/skill-freshness-check.sh b/skills/blocks-api/scripts/skill-freshness-check.sh new file mode 100755 index 0000000..5cbad39 --- /dev/null +++ b/skills/blocks-api/scripts/skill-freshness-check.sh @@ -0,0 +1,257 @@ +#!/usr/bin/env bash +# Keep the installed blocks-api skill fresh without getting in the agent's way. +# +# The agent runs this once per invocation of the skill. The script decides +# whether an update is due, runs `npx skills update` from the right directory, +# and reports a single line on stdout only when the skill files actually +# changed. It never fails: every path exits 0, so a broken network or a +# missing npx can never block the task the agent was asked to do. +# +# Throttling (per scope root, tracked in a small marker file): +# - after a successful check, stay quiet for 24 hours +# - after a failed check, retry 15 minutes later +# - after 3 consecutive failures, back off to one attempt per day +# - concurrent runs share a lock; only the first one checks, the rest skip +# +# Environment: +# BLOCKS_SKILLS_NO_UPDATE=1 opt out entirely (exit 0 immediately) +# BLOCKS_SKILLS_DEBUG=1 log every decision to stderr +# BLOCKS_SKILLS_NOW override the clock (epoch seconds), for tests +# BLOCKS_SKILLS_UPDATE_CMD override the update command, for tests +# BLOCKS_SKILLS_TIMEOUT override the update deadline (seconds), for tests +# BLOCKS_SKILLS_ROOT override the detected scope root, for tests +# XDG_CACHE_HOME marker files live under $XDG_CACHE_HOME/blocks-skills +# (defaults to ~/.cache) + +SKILL_NAME="blocks-api" +SKILLS_CLI_VERSION="1.7.0" + +SUCCESS_INTERVAL=86400 # 24h: do not re-check after a success +RETRY_INTERVAL=900 # 15m: retry after a failure +MAX_FAILURES=3 # after this many failures in a row, retry daily +UPDATE_TIMEOUT=120 # seconds before a stalled update is killed + +log() { + if [ -n "${BLOCKS_SKILLS_DEBUG:-}" ]; then + printf 'skill-freshness-check: %s\n' "$*" >&2 + fi +} + +if [ -n "${BLOCKS_SKILLS_NO_UPDATE:-}" ]; then + log "BLOCKS_SKILLS_NO_UPDATE is set; skipping" + exit 0 +fi + +# --- locate ourselves --------------------------------------------------------- +# `pwd -P` resolves symlinks, so being invoked through ~/.claude/skills/ +# still lands on the real ~/.agents/skills/ directory. +script_dir=$(cd "$(dirname "$0")" 2>/dev/null && pwd -P) || exit 0 +skill_dir=$(cd "$script_dir/.." 2>/dev/null && pwd -P) || exit 0 + +# The skills CLI reads project locks from the current directory and global +# locks from the home directory. Walk up from the skill until we hit the +# directory that owns the `.agents/` (or `.claude/`) tree; that is where +# `npx skills update` must run so both scopes are considered. +find_root() { + dir=$skill_dir + while [ "$dir" != "/" ] && [ -n "$dir" ]; do + dir=$(dirname "$dir") + if [ -d "$dir/.agents" ] || [ -d "$dir/.claude" ]; then + printf '%s\n' "$dir" + return 0 + fi + done + return 1 +} + +if [ -n "${BLOCKS_SKILLS_ROOT:-}" ]; then + root=$BLOCKS_SKILLS_ROOT +elif ! root=$(find_root); then + root=${HOME:-} +fi +if [ -z "$root" ] || [ ! -d "$root" ]; then + log "could not determine a scope root; skipping" + exit 0 +fi +log "skill dir: $skill_dir" +log "scope root: $root" + +# --- marker file -------------------------------------------------------------- +cache_home=${XDG_CACHE_HOME:-${HOME:-}/.cache} +if [ -z "${XDG_CACHE_HOME:-}" ] && [ -z "${HOME:-}" ]; then + log "neither XDG_CACHE_HOME nor HOME is set; skipping" + exit 0 +fi +root_hash=$(printf '%s' "$root" | cksum | cut -d' ' -f1) +marker_dir="$cache_home/blocks-skills/$root_hash" +marker="$marker_dir/$SKILL_NAME" +if ! mkdir -p "$marker_dir" 2>/dev/null; then + log "cannot create $marker_dir; skipping" + exit 0 +fi + +deadline=${BLOCKS_SKILLS_TIMEOUT:-$UPDATE_TIMEOUT} +case $deadline in + ''|*[!0-9]*) deadline=$UPDATE_TIMEOUT ;; +esac + +# --- lock --------------------------------------------------------------------- +# Several agent sessions can start at the same moment. `mkdir` is atomic, so +# whoever creates the lock directory reads the marker and runs the update; +# everyone else skips straight away rather than waiting. A lock older than the +# update deadline plus a minute belongs to a run that was killed, so take it +# over. +lock="$marker.lock" +if ! mkdir "$lock" 2>/dev/null; then + stale_after=$(( (deadline + 60 + 59) / 60 )) # minutes, rounded up + if [ -n "$(find "$lock" -maxdepth 0 -mmin +"$stale_after" 2>/dev/null)" ]; then + log "taking over stale lock $lock" + rmdir "$lock" 2>/dev/null + if ! mkdir "$lock" 2>/dev/null; then + log "lost the race for $lock; skipping" + exit 0 + fi + else + log "another check holds $lock; skipping" + exit 0 + fi +fi +trap 'rmdir "$lock" 2>/dev/null' EXIT + +now=${BLOCKS_SKILLS_NOW:-$(date +%s)} +case $now in + ''|*[!0-9]*) log "invalid clock value '$now'; skipping"; exit 0 ;; +esac + +last_success=0 +last_attempt=0 +failures=0 +if [ -f "$marker" ]; then + while IFS='=' read -r key value; do + case $value in ''|*[!0-9]*) continue ;; esac + case $key in + last_success) last_success=$value ;; + last_attempt) last_attempt=$value ;; + failures) failures=$value ;; + esac + done < "$marker" +fi +log "marker: $marker (last_success=$last_success last_attempt=$last_attempt failures=$failures)" + +write_marker() { + tmp="$marker.$$.tmp" + { + printf 'last_success=%s\n' "$last_success" + printf 'last_attempt=%s\n' "$last_attempt" + printf 'failures=%s\n' "$failures" + } > "$tmp" 2>/dev/null && mv -f "$tmp" "$marker" 2>/dev/null + rm -f "$tmp" 2>/dev/null +} + +# --- is an update due? -------------------------------------------------------- +if [ $((now - last_success)) -lt $SUCCESS_INTERVAL ]; then + log "checked successfully $((now - last_success))s ago; not due" + exit 0 +fi + +backoff=$RETRY_INTERVAL +if [ "$failures" -ge $MAX_FAILURES ]; then + backoff=$SUCCESS_INTERVAL +fi +if [ $((now - last_attempt)) -lt $backoff ]; then + log "last attempt was $((now - last_attempt))s ago (backoff ${backoff}s, failures=$failures); not due" + exit 0 +fi + +# --- run the update ----------------------------------------------------------- +fingerprint() { + ( + cd "$skill_dir" 2>/dev/null || exit 0 + for f in SKILL.md references/*.md scripts/*.sh; do + [ -f "$f" ] && cksum "$f" + done | cksum + ) +} + +update_cmd=${BLOCKS_SKILLS_UPDATE_CMD:-"npx --yes skills@$SKILLS_CLI_VERSION update $SKILL_NAME -y"} +if [ -z "${BLOCKS_SKILLS_UPDATE_CMD:-}" ] && ! command -v npx >/dev/null 2>&1; then + log "npx not found on PATH; skipping" + exit 0 +fi + +# Run a command with a deadline using only bash builtins, so the guarantee +# holds on systems without coreutils' `timeout` (stock macOS, for one). +# Job control puts the child and the watchdog in their own process groups, +# which lets us take down the whole npx/node tree rather than just the +# wrapping shell. Returns the command's exit code, or 124 on the deadline. +run_with_deadline() { # run_with_deadline + set -m + ( cd "$root" && exec sh -c "$3" ) > "$2" 2>&1 "$2.timeout" + kill -TERM -- "-$child" 2>/dev/null || kill -TERM "$child" 2>/dev/null + sleep 1 + kill -KILL -- "-$child" 2>/dev/null || kill -KILL "$child" 2>/dev/null + ) >/dev/null 2>&1 /dev/null + status=$? + kill -KILL -- "-$watchdog" 2>/dev/null || kill -KILL "$watchdog" 2>/dev/null + wait "$watchdog" 2>/dev/null + if [ -f "$2.timeout" ]; then + rm -f "$2.timeout" 2>/dev/null + return 124 + fi + return "$status" +} + +last_attempt=$now +write_marker + +before=$(fingerprint) +log "running: $update_cmd (in $root, deadline ${deadline}s)" +output_file="$marker.$$.out" +run_with_deadline "$deadline" "$output_file" "$update_cmd" +rc=$? +output=$(cat "$output_file" 2>/dev/null) +rm -f "$output_file" 2>/dev/null +after=$(fingerprint) +if [ "$rc" -eq 124 ]; then + log "update killed after ${deadline}s" +else + log "update exit code: $rc" +fi +if [ -n "${BLOCKS_SKILLS_DEBUG:-}" ]; then + esc=$(printf '\033') + printf '%s\n' "$output" | sed "s/$esc\[[0-9;?]*[a-zA-Z]//g; s/^/skill-freshness-check: | /" >&2 +fi + +# The CLI exits 0 for most of its own error paths, so look at the output too. +failed=0 +if [ "$rc" -ne 0 ]; then + failed=1 +elif printf '%s\n' "$output" | grep -qiE 'failed|cannot update|npm err|error'; then + failed=1 +fi + +if [ "$failed" -eq 1 ]; then + failures=$((failures + 1)) + write_marker + log "update failed (failures=$failures)" + exit 0 +fi + +last_success=$now +failures=0 +write_marker + +if [ "$before" != "$after" ]; then + log "skill files changed" + printf 'Note: the %s skill was refreshed to the latest published version. The updated instructions take effect in your next session.\n' "$SKILL_NAME" +else + log "skill already up to date" +fi +exit 0 diff --git a/skills/blocks-api/scripts/skill-freshness-check.test.sh b/skills/blocks-api/scripts/skill-freshness-check.test.sh new file mode 100755 index 0000000..9f15c29 --- /dev/null +++ b/skills/blocks-api/scripts/skill-freshness-check.test.sh @@ -0,0 +1,249 @@ +#!/usr/bin/env bash +# Tests for skill-freshness-check.sh. +# +# Copies the skill into a throwaway project layout (/.agents/skills/ +# blocks-api) so the script resolves its scope root the same way it does after +# a real `npx skills add`, then drives it with a fake clock and a fake update +# command. Run with: bash skills/blocks-api/scripts/skill-freshness-check.test.sh +set -u + +here=$(cd "$(dirname "$0")" && pwd -P) +skill_src=$(cd "$here/.." && pwd -P) + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +export XDG_CACHE_HOME="$tmp/cache" +export HOME="$tmp/home" +mkdir -p "$HOME" +unset BLOCKS_SKILLS_NO_UPDATE BLOCKS_SKILLS_DEBUG BLOCKS_SKILLS_ROOT + +root="$tmp/project" +skill_dir="$root/.agents/skills/blocks-api" +mkdir -p "$root/.agents/skills" +cp -R "$skill_src" "$skill_dir" +script="$skill_dir/scripts/skill-freshness-check.sh" + +# The fake update command records each invocation and acts on a mode file: +# ok print an "up to date" message, exit 0 +# change append to SKILL.md so the fingerprint changes, exit 0 +# fail print the CLI's failure wording, exit 0 (as the real CLI does) +# exit1 exit non-zero without saying anything +# missing print the CLI's "not installed" message, exit 0 +# hang spawn a long sleep (pid written to $hung_pid) and wait on it +# slow sleep a second, then report "up to date" +calls="$tmp/calls" +mode="$tmp/mode" +hung_pid="$tmp/hung-pid" +fake="$tmp/fake-update.sh" +cat > "$fake" <> "$calls" +case \$(cat "$mode") in + ok) echo "✓ All global skills are up to date" ;; + change) echo " ✓ Updated blocks-api"; echo "# changed" >> "$skill_dir/SKILL.md" ;; + fail) echo " ✗ Failed to check skills from BlocksOrg/skills" ;; + exit1) exit 1 ;; + missing) echo "No installed skills found matching: blocks-api" ;; + hang) sleep 300 & echo \$! > "$hung_pid"; wait ;; + slow) sleep 1; echo "✓ All global skills are up to date" ;; +esac +exit 0 +FAKE +chmod +x "$fake" +export BLOCKS_SKILLS_UPDATE_CMD="$fake" + +T0=1700000000 +MIN=60 +HOUR=3600 + +pass=0 +fail=0 +fail_msgs="" + +run() { # run -> sets $out and $rc + out=$(BLOCKS_SKILLS_NOW=$1 bash "$script" 2>"$tmp/stderr") + rc=$? +} + +not_running() { ! kill -0 "$1" 2>/dev/null; } + +call_count() { + if [ -f "$calls" ]; then wc -l < "$calls" | tr -d ' '; else echo 0; fi +} + +reset() { + rm -rf "$XDG_CACHE_HOME" "$calls" + echo ok > "$mode" +} + +check() { # check + desc=$1; shift + if "$@"; then + pass=$((pass + 1)) + else + fail=$((fail + 1)) + fail_msgs="$fail_msgs + ✗ $desc" + echo "FAIL: $desc" >&2 + fi +} + +# --- first run updates, second run within 24h does not -------------------------- +reset +run $T0 +check "first run exits 0" [ "$rc" -eq 0 ] +check "first run invokes the update command" [ "$(call_count)" -eq 1 ] +check "first run is silent when nothing changed" [ -z "$out" ] +check "update runs from the scope root" [ "$(head -n1 "$calls")" = "$(cd "$root" && pwd -P)" ] + +run $((T0 + 1 * HOUR)) +check "second run within 24h exits 0" [ "$rc" -eq 0 ] +check "second run within 24h skips the update" [ "$(call_count)" -eq 1 ] + +run $((T0 + 23 * HOUR)) +check "run at 23h still skips" [ "$(call_count)" -eq 1 ] + +run $((T0 + 25 * HOUR)) +check "run after 24h updates again" [ "$(call_count)" -eq 2 ] + +# --- a real change prints exactly one note --------------------------------------- +reset +echo change > "$mode" +run $T0 +check "changed files exit 0" [ "$rc" -eq 0 ] +check "changed files print a one-line note" [ "$(printf '%s\n' "$out" | wc -l | tr -d ' ')" -eq 1 ] +case $out in + *"blocks-api skill was refreshed"*) check "note names the skill" true ;; + *) check "note names the skill" false ;; +esac +echo ok > "$mode" +run $((T0 + 1 * HOUR)) +check "no note on the throttled run that follows" [ -z "$out" ] + +# --- failures retry after 15 minutes, then back off to daily --------------------- +reset +echo fail > "$mode" +run $T0 # failure 1 +check "failure exits 0" [ "$rc" -eq 0 ] +check "failure prints nothing" [ -z "$out" ] +check "failure 1 recorded" [ "$(call_count)" -eq 1 ] + +run $((T0 + 5 * MIN)) +check "no retry 5 minutes after a failure" [ "$(call_count)" -eq 1 ] + +run $((T0 + 16 * MIN)) # failure 2 +check "retry 16 minutes after a failure" [ "$(call_count)" -eq 2 ] + +run $((T0 + 32 * MIN)) # failure 3 +check "third attempt after another 16 minutes" [ "$(call_count)" -eq 3 ] + +run $((T0 + 48 * MIN)) +check "after 3 failures, no retry 16 minutes later" [ "$(call_count)" -eq 3 ] + +run $((T0 + 32 * MIN + 23 * HOUR)) +check "after 3 failures, still no retry at 23h" [ "$(call_count)" -eq 3 ] + +echo ok > "$mode" +run $((T0 + 32 * MIN + 25 * HOUR)) # success resets the counter +check "after 3 failures, retries once a day" [ "$(call_count)" -eq 4 ] + +echo fail > "$mode" +run $((T0 + 32 * MIN + 50 * HOUR)) # failure 1 again +run $((T0 + 32 * MIN + 50 * HOUR + 16 * MIN)) +check "a success resets the failure counter (15m retry again)" [ "$(call_count)" -eq 6 ] + +# --- non-zero exit from the update command counts as a failure ------------------ +reset +echo exit1 > "$mode" +run $T0 +check "non-zero update exit still exits 0" [ "$rc" -eq 0 ] +run $((T0 + 16 * MIN)) +check "non-zero update exit is retried after 15 minutes" [ "$(call_count)" -eq 2 ] + +# --- 'not installed' is not a failure; check again tomorrow, not in 15 minutes ---- +reset +echo missing > "$mode" +run $T0 +check "untracked install exits 0" [ "$rc" -eq 0 ] +check "untracked install prints nothing" [ -z "$out" ] +run $((T0 + 16 * MIN)) +check "untracked install is not retried after 15 minutes" [ "$(call_count)" -eq 1 ] + +# --- a stalled update is killed at the deadline and counted as a failure --------- +reset +echo hang > "$mode" +started=$(date +%s) +BLOCKS_SKILLS_TIMEOUT=2 run $T0 +elapsed=$(( $(date +%s) - started )) +check "stalled update exits 0" [ "$rc" -eq 0 ] +check "stalled update prints nothing" [ -z "$out" ] +check "stalled update returns within the deadline (took ${elapsed}s)" [ "$elapsed" -le 10 ] +check "stalled update's whole process tree is killed" not_running "$(cat "$hung_pid")" +run $((T0 + 5 * MIN)) +check "stalled update counts as a failure (no retry at 5m)" [ "$(call_count)" -eq 1 ] +echo ok > "$mode" +run $((T0 + 16 * MIN)) +check "stalled update is retried after 15 minutes" [ "$(call_count)" -eq 2 ] + +# --- concurrent sessions run the update once ------------------------------------ +reset +echo slow > "$mode" +for i in 1 2 3 4 5 6 7 8 9 10; do + BLOCKS_SKILLS_NOW=$T0 bash "$script" > "$tmp/concurrent.$i" 2>&1 & +done +concurrent_rc=0 +wait || concurrent_rc=$? +for i in 1 2 3 4 5 6 7 8 9 10; do + [ -s "$tmp/concurrent.$i" ] && concurrent_rc=1 +done +check "10 concurrent first runs invoke the update once" [ "$(call_count)" -eq 1 ] +check "10 concurrent first runs all exit 0 silently" [ "$concurrent_rc" -eq 0 ] +lock="$XDG_CACHE_HOME/blocks-skills/$(printf '%s' "$(cd "$root" && pwd -P)" | cksum | cut -d' ' -f1)/blocks-api.lock" +check "the lock is released afterwards" [ ! -e "$lock" ] + +# --- a held lock makes the run skip; a stale one is taken over ------------------ +reset +mkdir -p "$lock" +run $T0 +check "held lock exits 0" [ "$rc" -eq 0 ] +check "held lock skips the update" [ "$(call_count)" -eq 0 ] +check "held lock is left for its owner" [ -d "$lock" ] +touch -t 202001010000 "$lock" +run $T0 +check "stale lock is taken over and the update runs" [ "$(call_count)" -eq 1 ] +check "stale lock is released afterwards" [ ! -e "$lock" ] + +# --- opt-out -------------------------------------------------------------------- +reset +BLOCKS_SKILLS_NO_UPDATE=1 run $T0 +check "opt-out exits 0" [ "$rc" -eq 0 ] +check "opt-out prints nothing" [ -z "$out" ] +check "opt-out never invokes the update command" [ "$(call_count)" -eq 0 ] + +# --- a missing update command can never break the caller ------------------------ +reset +BLOCKS_SKILLS_UPDATE_CMD="$tmp/does-not-exist" run $T0 +check "missing update command exits 0" [ "$rc" -eq 0 ] +check "missing update command prints nothing" [ -z "$out" ] + +# --- an unwritable cache dir is skipped quietly --------------------------------- +reset +bad_cache="$tmp/not-a-dir" +touch "$bad_cache" +XDG_CACHE_HOME="$bad_cache" run $T0 +check "unwritable cache exits 0" [ "$rc" -eq 0 ] +check "unwritable cache skips the update" [ "$(call_count)" -eq 0 ] + +# --- debug logging goes to stderr only ------------------------------------------ +reset +out=$(BLOCKS_SKILLS_NOW=$T0 BLOCKS_SKILLS_DEBUG=1 bash "$script" 2>"$tmp/stderr") +check "debug keeps stdout clean" [ -z "$out" ] +check "debug logs the decision to stderr" grep -q 'scope root' "$tmp/stderr" + +echo +echo "$pass passed, $fail failed" +if [ "$fail" -ne 0 ]; then + echo "$fail_msgs" + exit 1 +fi