Commit c98e017f88d3

Vincent Demeester <vincent@sbr.pm>
2026-03-30 15:05:01
feat: add read-only Slack skill
Dual-backend Slack reader supporting Red Hat Enterprise Grid (curl + passage) and Tekton (slackdump). Provides channel history, thread reading, search, and user lookup with agent-optimized compact text output.
1 parent 7c412e2
Changed files (2)
dots
config
claude
skills
dots/config/claude/skills/Slack/tools/SlackRead.sh
@@ -0,0 +1,644 @@
+#!/usr/bin/env bash
+# SlackRead.sh: Read-only Slack CLI
+# Supports two backends:
+#   - curl: for Enterprise Grid workspaces (Red Hat) using passage-stored tokens
+#   - slackdump: for regular workspaces (Tekton) using slackdump's auth
+set -euo pipefail
+
+# --- Configuration ---
+SLACKDUMP="${SLACKDUMP_BIN:-slackdump}"
+WORKSPACE=""
+BACKEND=""  # auto-detect: "curl" or "slackdump"
+CACHE_DIR="${SLACK_READ_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/slack-read}"
+USER_CACHE_MAX_AGE=86400 # 24 hours
+
+# Known workspaces and their backends
+declare -A WORKSPACE_BACKENDS=(
+  [redhat]=curl
+  [tekton]=slackdump
+)
+# Mapping from our workspace name to slackdump's workspace name
+declare -A SLACKDUMP_WORKSPACE_NAMES=(
+  [tekton]=default
+)
+# Default workspace
+DEFAULT_WORKSPACE="redhat"
+
+# Parse global flags before subcommand
+while [[ $# -gt 0 ]]; do
+  case "$1" in
+    --workspace|-w) WORKSPACE="$2"; shift 2 ;;
+    *) break ;;
+  esac
+done
+
+# Set workspace and backend
+WORKSPACE="${WORKSPACE:-$DEFAULT_WORKSPACE}"
+BACKEND="${WORKSPACE_BACKENDS[$WORKSPACE]:-slackdump}"
+
+# Workspace-specific cache
+CACHE_DIR="$CACHE_DIR/$WORKSPACE"
+USER_CACHE="$CACHE_DIR/users.json"
+mkdir -p "$CACHE_DIR"
+
+# --- Helpers ---
+die() { echo "ERROR: $*" >&2; exit 1; }
+
+# --- Curl Backend (Enterprise Grid) ---
+
+# Get token and cookie from passage
+curl_get_token() { passage show "slack/$WORKSPACE/token" 2>/dev/null || die "No token in passage at slack/$WORKSPACE/token"; }
+curl_get_cookie() { passage show "slack/$WORKSPACE/cookie" 2>/dev/null || die "No cookie in passage at slack/$WORKSPACE/cookie"; }
+
+# Make authenticated Slack API call
+slack_api() {
+  local method="$1"; shift
+  local token cookie response
+  token=$(curl_get_token)
+  cookie=$(curl_get_cookie)
+
+  response=$(curl -s -X POST "https://slack.com/api/$method" \
+    -H "Authorization: Bearer $token" \
+    -b "d=$cookie" \
+    "$@")
+
+  local ok
+  ok=$(echo "$response" | jq -r '.ok' 2>/dev/null)
+  if [[ "$ok" != "true" ]]; then
+    local err
+    err=$(echo "$response" | jq -r '.error // "unknown"' 2>/dev/null)
+    die "Slack API $method failed: $err"
+  fi
+  echo "$response"
+}
+
+# --- User Cache ---
+
+ensure_user_cache() {
+  local rebuild=false
+  if [[ ! -f "$USER_CACHE" ]]; then
+    rebuild=true
+  else
+    local age=$(( $(date +%s) - $(stat -c %Y "$USER_CACHE" 2>/dev/null || echo 0) ))
+    [[ $age -gt $USER_CACHE_MAX_AGE ]] && rebuild=true
+  fi
+
+  if [[ "$rebuild" == "true" ]]; then
+    echo "Refreshing user cache..." >&2
+    if [[ "$BACKEND" == "curl" ]]; then
+      curl_build_user_cache
+    else
+      slackdump_build_user_cache
+    fi
+  fi
+}
+
+curl_build_user_cache() {
+  # Enterprise Grid blocks users.list, so start with empty cache
+  # and populate incrementally from search results
+  if [[ ! -f "$USER_CACHE" ]]; then
+    echo '{}' > "$USER_CACHE"
+  fi
+}
+
+# Add user to cache from search results (incremental)
+cache_user_from_result() {
+  local user_id="$1" username="$2"
+  if [[ -f "$USER_CACHE" ]] && [[ -n "$user_id" ]] && [[ -n "$username" ]] && [[ "$username" != "null" ]]; then
+    local existing
+    existing=$(jq -r --arg id "$user_id" '.[$id] // ""' "$USER_CACHE" 2>/dev/null)
+    if [[ -z "$existing" ]]; then
+      local tmp
+      tmp=$(jq --arg id "$user_id" --arg name "$username" '. + {($id): $name}' "$USER_CACHE")
+      echo "$tmp" > "$USER_CACHE"
+    fi
+  fi
+}
+
+slackdump_build_user_cache() {
+  local tmpdir
+  tmpdir=$(mktemp -d /tmp/slack-read-users-XXXXXX)
+  (cd "$tmpdir" && "$SLACKDUMP" list users -format json -q -y $(ws_flags) 2>/dev/null)
+  local json_file
+  json_file=$(find "$tmpdir" -name 'users-*.json' -type f 2>/dev/null | head -1)
+  if [[ -n "$json_file" ]] && [[ -s "$json_file" ]]; then
+    jq 'map({
+      (.id): (
+        if (.profile.display_name // "") != "" then .profile.display_name
+        elif (.profile.real_name // "") != "" then .profile.real_name
+        elif (.name // "") != "" then .name
+        else .id end
+      )
+    }) | add // {}' "$json_file" > "$USER_CACHE"
+  else
+    echo '{}' > "$USER_CACHE"
+  fi
+  rm -rf "$tmpdir"
+}
+
+resolve_user() {
+  local user_id="$1"
+  if [[ -f "$USER_CACHE" ]]; then
+    local name
+    name=$(jq -r --arg id "$user_id" '.[$id] // empty' "$USER_CACHE" 2>/dev/null)
+    [[ -n "$name" ]] && { echo "$name"; return; }
+  fi
+  echo "$user_id"
+}
+
+# --- Text Formatting ---
+
+clean_text() {
+  local text="$1"
+  # Resolve <@UID|name> mentions
+  text=$(echo "$text" | sed -E 's/<@(U[A-Z0-9]+)\|([^>]+)>/@\2/g')
+  # Resolve <@UID> mentions
+  while [[ "$text" =~ \<@(U[A-Z0-9]+)\> ]]; do
+    local uid="${BASH_REMATCH[1]}"
+    local uname
+    uname=$(resolve_user "$uid")
+    text="${text//<@$uid>/@$uname}"
+  done
+  # <URL|label> -> label (URL)
+  text=$(echo "$text" | sed -E 's/<(https?:[^|>]+)\|([^>]+)>/\2 (\1)/g')
+  # <URL> -> URL
+  text=$(echo "$text" | sed -E 's/<(https?:[^>]+)>/\1/g')
+  # <#C123|name> -> #name
+  text=$(echo "$text" | sed -E 's/<#[A-Z0-9]+\|([^>]+)>/#\1/g')
+  echo "$text"
+}
+
+format_ts() {
+  local ts="$1"
+  local unix_ts="${ts%%.*}"
+  date -d "@$unix_ts" "+%Y-%m-%d %H:%M" 2>/dev/null || echo "$ts"
+}
+
+format_message() {
+  local msg="$1"
+  local indent="${2:-}"
+  local user_id ts text reply_count
+  user_id=$(echo "$msg" | jq -r '.user // "unknown"')
+  ts=$(echo "$msg" | jq -r '.ts')
+  text=$(echo "$msg" | jq -r '.text // ""')
+  reply_count=$(echo "$msg" | jq -r '.reply_count // 0')
+
+  local username
+  username=$(resolve_user "$user_id")
+  local time_str
+  time_str=$(format_ts "$ts")
+  local clean
+  clean=$(clean_text "$text")
+
+  local reply_indicator=""
+  [[ "$reply_count" -gt 0 ]] && reply_indicator=" [${reply_count} replies]"
+
+  echo "${indent}${username} [${time_str}]:${reply_indicator} ${clean}"
+}
+
+# --- Slackdump helpers ---
+
+ws_flags() {
+  local sd_name="${SLACKDUMP_WORKSPACE_NAMES[$WORKSPACE]:-$WORKSPACE}"
+  [[ -n "$sd_name" ]] && echo "-workspace" "$sd_name"
+}
+
+check_slackdump() {
+  command -v "$SLACKDUMP" &>/dev/null || die "slackdump not found. Install: nix profile install nixpkgs#slackdump"
+}
+
+# --- Commands ---
+
+cmd_help() {
+  cat <<'EOF'
+SlackRead.sh: Read-only Slack CLI
+
+Usage: SlackRead.sh [--workspace NAME] <command> [options]
+
+Global options:
+  --workspace, -w NAME    Target workspace (default: redhat)
+                          Known: redhat (curl), tekton (slackdump)
+
+Commands:
+  auth                    Check authentication status
+  channels [options]      List channels
+  history <channel> [options]  Read channel messages
+  thread <channel> <ts|URL>    Read a thread
+  search <query>          Search messages
+  users [query]           List or search users
+
+History options:
+  --since YYYY-MM-DD      Messages since date (default: 7 days ago)
+  --limit N               Max messages (default: 50, curl only)
+
+Examples:
+  SlackRead.sh auth
+  SlackRead.sh channels --member-only
+  SlackRead.sh history team-ocp-pipeline
+  SlackRead.sh history team-ocp-pipeline --since 2026-03-25
+  SlackRead.sh -w tekton history pipeline-dev
+  SlackRead.sh thread https://redhat.enterprise.slack.com/archives/C123/p456
+  SlackRead.sh search "release 1.22"
+  SlackRead.sh users vincent
+EOF
+}
+
+cmd_auth() {
+  if [[ "$BACKEND" == "curl" ]]; then
+    echo "Workspace: $WORKSPACE (curl backend)"
+    echo "Token source: passage slack/$WORKSPACE/token"
+    echo "Cookie source: passage slack/$WORKSPACE/cookie"
+    echo "---"
+    local result
+    result=$(slack_api "auth.test")
+    echo "$result" | jq -r '"Status: OK\nUser: \(.user)\nTeam: \(.team)\nEnterprise: \(.enterprise_id // "n/a")"'
+  else
+    check_slackdump
+    echo "Workspace: $WORKSPACE (slackdump backend)"
+    "$SLACKDUMP" workspace list -a 2>/dev/null || die "No workspace configured"
+  fi
+}
+
+cmd_channels() {
+  local member_only=false
+  local include_all=false
+  while [[ $# -gt 0 ]]; do
+    case "$1" in
+      --member-only|-m) member_only=true; shift ;;
+      --all|-a) include_all=true; shift ;;
+      *) shift ;;
+    esac
+  done
+
+  if [[ "$BACKEND" == "curl" ]]; then
+    # Enterprise Grid blocks conversations.list
+    # Suggest using search to discover channels instead
+    echo "Note: Enterprise Grid restricts channel listing."
+    echo "Use 'SlackRead.sh search \"in:#channel-name\"' to find channels."
+    echo "Or provide the channel ID directly to history/thread commands."
+    echo ""
+    echo "Common channels (from search history):"
+    # Show any channels we've seen in search results from cache
+    if [[ -f "$CACHE_DIR/known_channels.json" ]]; then
+      jq -r 'to_entries[] | "\(.key)\t#\(.value)"' "$CACHE_DIR/known_channels.json" 2>/dev/null | sort -t$'\t' -k2 | column -t -s$'\t'
+    else
+      echo "(none cached yet — run a search first)"
+    fi
+  else
+    check_slackdump
+    local flags="-no-json -format text -chan-types public_channel,private_channel"
+    [[ "$member_only" == "true" ]] && flags="$flags -member-only"
+    [[ "$include_all" == "true" ]] && flags="$flags -chan-types public_channel,private_channel,mpim,im"
+    "$SLACKDUMP" list channels $flags $(ws_flags) -y 2>/dev/null | grep -v '^\[' | grep -v '^$'
+  fi
+}
+
+cmd_history() {
+  local channel="${1:?Usage: SlackRead.sh history <channel> [--since DATE] [--limit N]}"
+  shift
+  local since="" limit=50
+  since=$(date -d "7 days ago" "+%Y-%m-%d" 2>/dev/null || echo "")
+
+  while [[ $# -gt 0 ]]; do
+    case "$1" in
+      --since|-s) since="$2"; shift 2 ;;
+      --limit|-l) limit="$2"; shift 2 ;;
+      *) shift ;;
+    esac
+  done
+
+  ensure_user_cache
+
+  if [[ "$BACKEND" == "curl" ]]; then
+    curl_history "$channel" "$since" "$limit"
+  else
+    slackdump_history "$channel" "$since"
+  fi
+}
+
+curl_resolve_channel() {
+  local channel="$1"
+  # If already a channel ID, return as-is
+  [[ "$channel" =~ ^[CDG][A-Z0-9]+$ ]] && { echo "$channel"; return; }
+
+  # Use search to discover channel ID from name
+  local response
+  response=$(slack_api "search.messages" \
+    -d "query=in:#${channel}" \
+    -d "count=1")
+  local chan_id
+  chan_id=$(echo "$response" | jq -r '.messages.matches[0].channel.id // ""')
+  if [[ -n "$chan_id" ]]; then
+    echo "$chan_id"
+  else
+    die "Channel '#${channel}' not found. Try using the channel ID directly."
+  fi
+}
+
+curl_history() {
+  local channel="$1" since="$2" limit="$3"
+  local channel_name="$channel"
+
+  # Resolve channel name to ID
+  local channel_id
+  channel_id=$(curl_resolve_channel "$channel")
+
+  # Convert since date to unix timestamp for oldest param
+  local oldest=""
+  if [[ -n "$since" ]]; then
+    oldest=$(date -d "$since" "+%s" 2>/dev/null || echo "")
+  fi
+
+  # Fetch via conversations.history (works on Enterprise Grid with channel ID)
+  local args=(-d "channel=$channel_id" -d "limit=$limit")
+  [[ -n "$oldest" ]] && args+=(-d "oldest=$oldest")
+
+  local response
+  response=$(slack_api "conversations.history" "${args[@]}")
+
+  local msg_count
+  msg_count=$(echo "$response" | jq '.messages | length')
+  echo "#${channel_name} — ${msg_count} messages"
+  echo "---"
+
+  # Messages come newest-first, reverse for chronological order
+  echo "$response" | jq -c '.messages | reverse | .[]' 2>/dev/null | while IFS= read -r msg; do
+    local user_id username ts text reply_count
+    user_id=$(echo "$msg" | jq -r '.user // "unknown"')
+    username=$(echo "$msg" | jq -r '.user // "unknown"')
+    ts=$(echo "$msg" | jq -r '.ts')
+    text=$(echo "$msg" | jq -r '.text // ""')
+    reply_count=$(echo "$msg" | jq -r '.reply_count // 0')
+
+    # Resolve user - try cache, fallback to user_id
+    local display_name
+    display_name=$(resolve_user "$user_id")
+
+    local time_str
+    time_str=$(format_ts "$ts")
+    local clean
+    clean=$(clean_text "$text")
+
+    local reply_indicator=""
+    [[ "$reply_count" -gt 0 ]] && reply_indicator=" [${reply_count} replies]"
+
+    echo "${display_name} [${time_str}]:${reply_indicator} ${clean}"
+  done
+}
+
+slackdump_history() {
+  local channel="$1" since="$2"
+  check_slackdump
+
+  # Resolve channel name to ID if needed
+  if [[ ! "$channel" =~ ^[CDG] ]]; then
+    local resolved
+    resolved=$("$SLACKDUMP" list channels -no-json -format text -b $(ws_flags) -y 2>/dev/null \
+      | grep -i "#${channel}" | head -1 | awk '{print $1}')
+    [[ -n "$resolved" ]] && channel="$resolved" || die "Channel '${channel}' not found."
+  fi
+
+  local tmpdir
+  tmpdir=$(mktemp -d /tmp/slack-read-XXXXXX)
+  trap "rm -rf '$tmpdir'" EXIT
+
+  local dump_args=(-o "$tmpdir/dump.zip" -y)
+  [[ -n "$since" ]] && dump_args+=(-time-from "${since}T00:00:00")
+
+  "$SLACKDUMP" dump $(ws_flags) "${dump_args[@]}" "$channel" 2>/dev/null
+
+  if [[ -f "$tmpdir/dump.zip" ]]; then
+    local json_file
+    json_file=$(unzip -l "$tmpdir/dump.zip" 2>/dev/null | grep '\.json$' | awk '{print $NF}' | head -1)
+    if [[ -n "$json_file" ]]; then
+      unzip -p "$tmpdir/dump.zip" "$json_file" > "$tmpdir/messages.json"
+      local channel_name msg_count
+      channel_name=$(jq -r '.name // .channel_id // "unknown"' "$tmpdir/messages.json")
+      msg_count=$(jq '.messages | length' "$tmpdir/messages.json")
+
+      if [[ "$msg_count" -eq 0 ]]; then
+        echo "No messages found in #${channel_name}"
+        return
+      fi
+
+      echo "#${channel_name} — ${msg_count} messages"
+      echo "---"
+
+      jq -c '.messages[]' "$tmpdir/messages.json" | while IFS= read -r msg; do
+        format_message "$msg"
+        # Thread replies inline
+        local replies
+        replies=$(echo "$msg" | jq -c '.slackdump_thread_replies // [] | .[1:]')
+        if [[ "$replies" != "[]" ]]; then
+          echo "$replies" | jq -c '.[]' | while IFS= read -r reply; do
+            format_message "$reply" "  ↳ "
+          done
+        fi
+      done
+    else
+      echo "No messages found."
+    fi
+  else
+    die "Failed to dump channel $channel"
+  fi
+}
+
+cmd_thread() {
+  local input="${1:?Usage: SlackRead.sh thread <channel> <ts> OR SlackRead.sh thread <URL>}"
+  shift
+  local channel="" thread_ts=""
+
+  # Parse Slack URL
+  if [[ "$input" =~ ^https:// ]]; then
+    channel=$(echo "$input" | sed -E 's|.*/archives/([^/]+)/.*|\1|')
+    local raw_ts
+    raw_ts=$(echo "$input" | sed -E 's|.*/p([0-9]+)$|\1|')
+    thread_ts="${raw_ts:0:10}.${raw_ts:10}"
+  else
+    channel="$input"
+    thread_ts="${1:?Usage: SlackRead.sh thread <channel> <ts>}"
+    shift || true
+  fi
+
+  ensure_user_cache
+
+  if [[ "$BACKEND" == "curl" ]]; then
+    local response
+    response=$(slack_api "conversations.replies" \
+      -d "channel=$channel" \
+      -d "ts=$thread_ts" \
+      -d "limit=100")
+
+    local msg_count
+    msg_count=$(echo "$response" | jq '.messages | length')
+    echo "Thread — ${msg_count} messages"
+    echo "---"
+
+    echo "$response" | jq -c '.messages[]' | while IFS= read -r msg; do
+      format_message "$msg"
+    done
+  else
+    check_slackdump
+    local tmpdir
+    tmpdir=$(mktemp -d /tmp/slack-read-XXXXXX)
+    trap "rm -rf '$tmpdir'" EXIT
+
+    "$SLACKDUMP" dump $(ws_flags) -o "$tmpdir/dump.zip" -y "${channel}:${thread_ts}" 2>/dev/null
+
+    if [[ -f "$tmpdir/dump.zip" ]]; then
+      local json_file
+      json_file=$(unzip -l "$tmpdir/dump.zip" 2>/dev/null | grep '\.json$' | awk '{print $NF}' | head -1)
+      if [[ -n "$json_file" ]]; then
+        unzip -p "$tmpdir/dump.zip" "$json_file" > "$tmpdir/thread.json"
+        local msg_count
+        msg_count=$(jq '.messages | length' "$tmpdir/thread.json")
+        echo "Thread — ${msg_count} messages"
+        echo "---"
+        jq -c '.messages[]' "$tmpdir/thread.json" | while IFS= read -r msg; do
+          format_message "$msg"
+        done
+      fi
+    fi
+  fi
+}
+
+cmd_search() {
+  local query="${1:?Usage: SlackRead.sh search <query>}"
+  shift
+
+  ensure_user_cache
+
+  if [[ "$BACKEND" == "curl" ]]; then
+    local response
+    response=$(slack_api "search.messages" \
+      -d "query=$query" \
+      -d "sort=timestamp" \
+      -d "sort_dir=desc" \
+      -d "count=20")
+
+    local total
+    total=$(echo "$response" | jq '.messages.total // 0')
+    echo "Search: \"${query}\" — ${total} results (showing up to 20)"
+    echo "---"
+
+    # Cache discovered channels and users from results
+    echo "$response" | jq -r '.messages.matches[] | "\(.channel.id)\t\(.channel.name)"' 2>/dev/null \
+      | sort -u | while IFS=$'\t' read -r cid cname; do
+        [[ -n "$cid" && -n "$cname" ]] && {
+          local known="${CACHE_DIR}/known_channels.json"
+          if [[ -f "$known" ]]; then
+            jq --arg id "$cid" --arg name "$cname" '. + {($id): $name}' "$known" > "$known.tmp" && mv "$known.tmp" "$known"
+          else
+            echo "{\"$cid\": \"$cname\"}" > "$known"
+          fi
+        }
+      done 2>/dev/null
+
+    echo "$response" | jq -r '.messages.matches[] | "\(.user // "")\t\(.username // "")"' 2>/dev/null \
+      | sort -u | while IFS=$'\t' read -r uid uname; do
+        cache_user_from_result "$uid" "$uname"
+      done 2>/dev/null
+
+    echo "$response" | jq -c '.messages.matches[]' 2>/dev/null | while IFS= read -r msg; do
+      local user_id ts text chan
+      user_id=$(echo "$msg" | jq -r '.user // .username // "unknown"')
+      ts=$(echo "$msg" | jq -r '.ts')
+      text=$(echo "$msg" | jq -r '.text // "" | gsub("\n"; " ") | .[0:300]')
+      chan=$(echo "$msg" | jq -r '.channel.name // "unknown"')
+
+      local username
+      username=$(echo "$msg" | jq -r '.username // ""')
+      [[ -z "$username" ]] && username=$(resolve_user "$user_id")
+      local time_str
+      time_str=$(format_ts "$ts")
+      local clean
+      clean=$(clean_text "$text")
+
+      echo "${username} [${time_str}] in #${chan}: ${clean}"
+    done
+  else
+    check_slackdump
+    local tmpdir
+    tmpdir=$(mktemp -d /tmp/slack-read-XXXXXX)
+    trap "rm -rf '$tmpdir'" EXIT
+
+    "$SLACKDUMP" search messages $(ws_flags) -o "$tmpdir" -y "$query" 2>/dev/null 1>/dev/null
+
+    if [[ -f "$tmpdir/slackdump.sqlite" ]]; then
+      local count
+      count=$(sqlite3 "$tmpdir/slackdump.sqlite" "SELECT count(*) FROM SEARCH_MESSAGE;" 2>/dev/null || echo 0)
+      echo "Search: \"${query}\" — ${count} results"
+      echo "---"
+
+      sqlite3 "$tmpdir/slackdump.sqlite" \
+        "SELECT CAST(DATA as TEXT) FROM SEARCH_MESSAGE ORDER BY TS DESC LIMIT 20;" 2>/dev/null \
+        | jq -r --slurpfile users "$USER_CACHE" '
+          ($users[0][.user] // .username // .user // "unknown") as $username |
+          (.channel.name // "unknown") as $chan |
+          (.ts // "0") as $ts |
+          (.text // "" | gsub("\n"; " ") | ltrimstr(" ") | .[0:300]) as $text |
+          "\($username)\t\($ts)\t\($chan)\t\($text)"
+        ' 2>/dev/null | while IFS=$'\t' read -r username ts chan text; do
+          [[ -z "$username" ]] && continue
+          local time_str
+          time_str=$(format_ts "$ts")
+          local clean
+          clean=$(clean_text "$text")
+          echo "${username} [${time_str}] in #${chan}: ${clean}"
+        done
+    else
+      echo "No results found."
+    fi
+  fi
+}
+
+cmd_users() {
+  local query="${1:-}"
+
+  if [[ "$BACKEND" == "curl" ]]; then
+    if [[ -n "$query" ]]; then
+      # Search for user via messages they've sent
+      local response
+      response=$(slack_api "search.messages" \
+        -d "query=from:@${query}" \
+        -d "count=5" \
+        -d "sort=timestamp" \
+        -d "sort_dir=desc")
+      echo "$response" | jq -r '.messages.matches[] | "\(.username // .user)\t\(.user)"' 2>/dev/null | sort -u | column -t -s$'\t'
+    else
+      # Show cached users
+      ensure_user_cache
+      if [[ -s "$USER_CACHE" ]] && [[ "$(cat "$USER_CACHE")" != "{}" ]]; then
+        jq -r 'to_entries[] | "\(.value)\t\(.key)"' "$USER_CACHE" | column -t -s$'\t' | head -50
+      else
+        echo "User cache is empty. Users are discovered incrementally from search results."
+        echo "Use: SlackRead.sh users <name> to search for a specific user."
+      fi
+    fi
+  else
+    check_slackdump
+    local users_output
+    users_output=$("$SLACKDUMP" list users -no-json -format text $(ws_flags) -y 2>&1 \
+      | grep -v '^\[' | grep -v '^$' | grep -v 'user cache')
+    if [[ -n "$query" ]]; then
+      echo "$users_output" | grep -i "$query"
+    else
+      echo "$users_output" | head -50
+    fi
+  fi
+}
+
+# --- Main ---
+cmd="${1:-help}"
+shift || true
+
+case "$cmd" in
+  auth)     cmd_auth "$@" ;;
+  channels) cmd_channels "$@" ;;
+  history)  cmd_history "$@" ;;
+  thread)   cmd_thread "$@" ;;
+  search)   cmd_search "$@" ;;
+  users)    cmd_users "$@" ;;
+  help|--help|-h) cmd_help ;;
+  *) die "Unknown command: $cmd. Run 'SlackRead.sh help' for usage." ;;
+esac
dots/config/claude/skills/Slack/SKILL.md
@@ -0,0 +1,191 @@
+---
+name: Slack
+description: Read-only Slack workspace access. USE WHEN user asks about Slack messages OR channel activity OR thread discussions OR searching Slack OR wants to catch up on conversations OR references a Slack URL.
+---
+
+# Slack
+
+Read-only access to Slack workspaces. Supports Enterprise Grid (Red Hat) via direct curl API and regular workspaces (Tekton) via slackdump.
+
+## Workflow Routing
+
+| Workflow | Trigger | Action |
+|----------|---------|--------|
+| **Read History** | "what happened in #channel", "catch up on channel" | `SlackRead.sh history <channel>` |
+| **Read Thread** | "read this thread", Slack URL pasted | `SlackRead.sh thread <url>` |
+| **Search** | "search slack for", "find discussion about" | `SlackRead.sh search <query>` |
+| **List Channels** | "list slack channels", "which channels" | `SlackRead.sh channels` |
+| **Find User** | "who is", "find user" | `SlackRead.sh users <query>` |
+
+## Tools
+
+| Tool | Purpose |
+|------|---------|
+| `tools/SlackRead.sh` | Main CLI — all Slack read operations |
+
+**Tool path:** `~/.config/claude/skills/Slack/tools/SlackRead.sh`
+
+## Setup
+
+### Red Hat Workspace (curl backend)
+
+Tokens stored in `passage` (age-encrypted password store):
+
+```bash
+# Store token and cookie from browser DevTools
+echo 'xoxc-...' | passage insert -m slack/redhat/token
+echo 'xoxd-...' | passage insert -m slack/redhat/cookie
+```
+
+**Getting tokens from browser:**
+1. Open Red Hat Slack in browser → DevTools (F12)
+2. **Network** tab → filter `api/` → click any request → copy `token` from request body
+3. **Storage** tab → Cookies → `.slack.com` → cookie `d` (starts with `xoxd-`)
+
+### Tekton Workspace (slackdump backend)
+
+```bash
+slackdump workspace new https://tektoncd.slack.com
+```
+
+Uses EZ-Login 3000 (browser-based, tokens encrypted in `~/.local/cache/slackdump/`).
+
+## Operational Guidance for the Agent
+
+1. **Default workspace is `redhat`** — use `-w tekton` for Tekton.
+2. **Red Hat uses search.messages for channel discovery** — `conversations.list` is blocked on Enterprise Grid.
+3. **Use channel IDs when known** — faster than name resolution for Red Hat.
+4. **Search is the most versatile command** on Enterprise Grid — use `in:#channel after:date` syntax.
+5. **User names resolve incrementally** on Red Hat — discovered from search results, not bulk-fetched.
+6. **Output is compact text** — summarize and present nicely to the user.
+7. **Read-only** — no sending, reacting, or modifying.
+8. **If auth fails** with `not_authed` or `invalid_auth`, tokens may have expired — ask user to refresh from browser.
+
+## Commands
+
+### Check Auth
+
+```bash
+~/.config/claude/skills/Slack/tools/SlackRead.sh auth
+~/.config/claude/skills/Slack/tools/SlackRead.sh -w tekton auth
+```
+
+### Read Channel History
+
+```bash
+# Red Hat (default workspace)
+~/.config/claude/skills/Slack/tools/SlackRead.sh history team-ocp-pipeline
+~/.config/claude/skills/Slack/tools/SlackRead.sh history team-ocp-pipeline --since 2026-03-25 --limit 20
+~/.config/claude/skills/Slack/tools/SlackRead.sh history CG5GV6CJD
+
+# Tekton
+~/.config/claude/skills/Slack/tools/SlackRead.sh -w tekton history pipeline-dev
+```
+
+### Read a Thread
+
+```bash
+# By channel ID + thread timestamp
+~/.config/claude/skills/Slack/tools/SlackRead.sh thread CG5GV6CJD 1774841643.132339
+
+# By Slack URL (tekton)
+~/.config/claude/skills/Slack/tools/SlackRead.sh -w tekton thread https://tektoncd.slack.com/archives/CLCCEBUMU/p1772459500074799
+```
+
+### Search Messages
+
+```bash
+# Slack search query syntax works
+~/.config/claude/skills/Slack/tools/SlackRead.sh search "release 1.22 in:#team-ocp-pipeline"
+~/.config/claude/skills/Slack/tools/SlackRead.sh search "from:@chmouel after:2026-03-20"
+~/.config/claude/skills/Slack/tools/SlackRead.sh -w tekton search "resolver caching"
+```
+
+### List/Search Users
+
+```bash
+~/.config/claude/skills/Slack/tools/SlackRead.sh users vincent
+~/.config/claude/skills/Slack/tools/SlackRead.sh -w tekton users vdemeest
+```
+
+## Output Format
+
+### Channel History
+```
+#channel-name — N messages
+---username [YYYY-MM-DD HH:MM]: [N replies] message text
+```
+
+### Search Results
+```
+Search: "query" — N results (showing up to 20)
+---
+username [YYYY-MM-DD HH:MM] in #channel: message text (max 300 chars)
+```
+
+### Thread
+```
+Thread — N messages
+---
+username [YYYY-MM-DD HH:MM]: message text
+```
+
+## Workspaces
+
+| Workspace | Backend | Auth | Notes |
+|-----------|---------|------|-------|
+| `redhat` (default) | curl | passage `slack/redhat/{token,cookie}` | Enterprise Grid — `conversations.list` blocked, use search |
+| `tekton` | slackdump | EZ-Login 3000 | Regular workspace — all features work |
+
+## Examples
+
+**Example 1: Catch up on team channel**
+```
+User: "What happened in #team-ocp-pipeline this week?"
+→ Run: SlackRead.sh history team-ocp-pipeline --since 2026-03-24
+→ Agent summarizes release planning, defects, discussions
+```
+
+**Example 2: Read a Slack thread**
+```
+User: "Read this thread CG5GV6CJD 1774841643.132339"
+→ Run: SlackRead.sh thread CG5GV6CJD 1774841643.132339
+→ Agent presents the full thread conversation
+```
+
+**Example 3: Search across Slack**
+```
+User: "Search for discussions about TLS on Slack"
+→ Run: SlackRead.sh search "TLS in:#team-ocp-pipeline"
+→ Agent summarizes relevant messages
+```
+
+**Example 4: Tekton workspace**
+```
+User: "What's happening in Tekton #pipeline-dev?"
+→ Run: SlackRead.sh -w tekton history pipeline-dev
+→ Agent summarizes upstream Tekton discussions
+```
+
+## Dependencies
+
+| Tool | Purpose | Source |
+|------|---------|-------|
+| `curl` + `jq` | Slack API calls (curl backend) | nixpkgs |
+| `passage` | Token/cookie storage (curl backend) | nixpkgs |
+| `slackdump` | Slack access (slackdump backend) | nixpkgs (v4.0.2) |
+| `sqlite3` | Search results (slackdump backend) | nixpkgs |
+
+## Limitations
+
+- **Read-only** — cannot send messages, react, or modify anything
+- **Enterprise Grid** — `conversations.list` and `users.list` are blocked; use search to discover channels and users
+- **Token expiry** — browser tokens (xoxc/xoxd) expire; re-extract from browser when auth fails
+- **Speed** — curl backend is fast (~2-5s), slackdump backend is slower (~15-25s)
+
+## Integration
+
+This skill works with:
+- **Email** skill — cross-reference Slack discussions with email threads
+- **GitHub** skill — follow up on PRs/issues mentioned in Slack messages
+- **Jira** skill — connect Slack discussions to Jira tickets (SRVKP-* references)