Commit c98e017f88d3
Changed files (2)
dots
config
claude
skills
Slack
tools
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)