flake-update-20260505
  1#!/usr/bin/env bash
  2# shellcheck disable=SC2046,SC2064,SC2086,SC2015
  3# slack-read.sh: Read-only Slack CLI
  4# Supports two backends:
  5#   - curl: for Enterprise Grid workspaces (Red Hat) using passage-stored tokens
  6#   - slackdump: for regular workspaces (Tekton) using slackdump's auth
  7set -euo pipefail
  8
  9# --- Configuration ---
 10SLACKDUMP="${SLACKDUMP_BIN:-slackdump}"
 11WORKSPACE=""
 12BACKEND=""  # auto-detect: "curl" or "slackdump"
 13CACHE_DIR="${SLACK_READ_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/slack-read}"
 14USER_CACHE_MAX_AGE=86400 # 24 hours
 15
 16# Known workspaces and their backends
 17declare -A WORKSPACE_BACKENDS=(
 18  [redhat]=curl
 19  [tekton]=slackdump
 20)
 21# Mapping from our workspace name to slackdump's workspace name
 22declare -A SLACKDUMP_WORKSPACE_NAMES=(
 23  [tekton]=default
 24)
 25# Default workspace
 26DEFAULT_WORKSPACE="redhat"
 27
 28# Parse global flags before subcommand
 29while [[ $# -gt 0 ]]; do
 30  case "$1" in
 31    --workspace|-w) WORKSPACE="$2"; shift 2 ;;
 32    *) break ;;
 33  esac
 34done
 35
 36# Set workspace and backend
 37WORKSPACE="${WORKSPACE:-$DEFAULT_WORKSPACE}"
 38BACKEND="${WORKSPACE_BACKENDS[$WORKSPACE]:-slackdump}"
 39
 40# Workspace-specific cache
 41CACHE_DIR="$CACHE_DIR/$WORKSPACE"
 42USER_CACHE="$CACHE_DIR/users.json"
 43mkdir -p "$CACHE_DIR"
 44
 45# --- Helpers ---
 46die() { echo "ERROR: $*" >&2; exit 1; }
 47
 48# --- Curl Backend (Enterprise Grid) ---
 49
 50# Get token and cookie from passage
 51curl_get_token() { passage show "slack/$WORKSPACE/token" 2>/dev/null || die "No token in passage at slack/$WORKSPACE/token"; }
 52curl_get_cookie() { passage show "slack/$WORKSPACE/cookie" 2>/dev/null || die "No cookie in passage at slack/$WORKSPACE/cookie"; }
 53
 54# Make authenticated Slack API call
 55slack_api() {
 56  local method="$1"; shift
 57  local token cookie response
 58  token=$(curl_get_token)
 59  cookie=$(curl_get_cookie)
 60
 61  response=$(curl -s -X POST "https://slack.com/api/$method" \
 62    -H "Authorization: Bearer $token" \
 63    -b "d=$cookie" \
 64    "$@")
 65
 66  local ok
 67  ok=$(echo "$response" | jq -r '.ok' 2>/dev/null)
 68  if [[ "$ok" != "true" ]]; then
 69    local err
 70    err=$(echo "$response" | jq -r '.error // "unknown"' 2>/dev/null)
 71    die "Slack API $method failed: $err"
 72  fi
 73  echo "$response"
 74}
 75
 76# --- User Cache ---
 77
 78ensure_user_cache() {
 79  local rebuild=false
 80  if [[ ! -f "$USER_CACHE" ]]; then
 81    rebuild=true
 82  else
 83    local age=$(( $(date +%s) - $(stat -c %Y "$USER_CACHE" 2>/dev/null || echo 0) ))
 84    [[ $age -gt $USER_CACHE_MAX_AGE ]] && rebuild=true
 85  fi
 86
 87  if [[ "$rebuild" == "true" ]]; then
 88    echo "Refreshing user cache..." >&2
 89    if [[ "$BACKEND" == "curl" ]]; then
 90      curl_build_user_cache
 91    else
 92      slackdump_build_user_cache
 93    fi
 94  fi
 95}
 96
 97curl_build_user_cache() {
 98  # Enterprise Grid blocks users.list, so start with empty cache
 99  # and populate incrementally from search results
100  if [[ ! -f "$USER_CACHE" ]]; then
101    echo '{}' > "$USER_CACHE"
102  fi
103}
104
105# Add user to cache from search results (incremental)
106cache_user_from_result() {
107  local user_id="$1" username="$2"
108  if [[ -f "$USER_CACHE" ]] && [[ -n "$user_id" ]] && [[ -n "$username" ]] && [[ "$username" != "null" ]]; then
109    local existing
110    existing=$(jq -r --arg id "$user_id" '.[$id] // ""' "$USER_CACHE" 2>/dev/null)
111    if [[ -z "$existing" ]]; then
112      local tmp
113      tmp=$(jq --arg id "$user_id" --arg name "$username" '. + {($id): $name}' "$USER_CACHE")
114      echo "$tmp" > "$USER_CACHE"
115    fi
116  fi
117}
118
119slackdump_build_user_cache() {
120  local tmpdir
121  tmpdir=$(mktemp -d /tmp/slack-read-users-XXXXXX)
122  (cd "$tmpdir" && "$SLACKDUMP" list users -format json -q -y $(ws_flags) 2>/dev/null)
123  local json_file
124  json_file=$(find "$tmpdir" -name 'users-*.json' -type f 2>/dev/null | head -1)
125  if [[ -n "$json_file" ]] && [[ -s "$json_file" ]]; then
126    jq 'map({
127      (.id): (
128        if (.profile.display_name // "") != "" then .profile.display_name
129        elif (.profile.real_name // "") != "" then .profile.real_name
130        elif (.name // "") != "" then .name
131        else .id end
132      )
133    }) | add // {}' "$json_file" > "$USER_CACHE"
134  else
135    echo '{}' > "$USER_CACHE"
136  fi
137  rm -rf "$tmpdir"
138}
139
140resolve_user() {
141  local user_id="$1"
142  if [[ -f "$USER_CACHE" ]]; then
143    local name
144    name=$(jq -r --arg id "$user_id" '.[$id] // empty' "$USER_CACHE" 2>/dev/null)
145    [[ -n "$name" ]] && { echo "$name"; return; }
146  fi
147  echo "$user_id"
148}
149
150# --- Text Formatting ---
151
152clean_text() {
153  local text="$1"
154  # Resolve <@UID|name> mentions
155  text=$(echo "$text" | sed -E 's/<@(U[A-Z0-9]+)\|([^>]+)>/@\2/g')
156  # Resolve <@UID> mentions
157  while [[ "$text" =~ \<@(U[A-Z0-9]+)\> ]]; do
158    local uid="${BASH_REMATCH[1]}"
159    local uname
160    uname=$(resolve_user "$uid")
161    text="${text//<@$uid>/@$uname}"
162  done
163  # <URL|label> -> label (URL)
164  text=$(echo "$text" | sed -E 's/<(https?:[^|>]+)\|([^>]+)>/\2 (\1)/g')
165  # <URL> -> URL
166  text=$(echo "$text" | sed -E 's/<(https?:[^>]+)>/\1/g')
167  # <#C123|name> -> #name
168  text=$(echo "$text" | sed -E 's/<#[A-Z0-9]+\|([^>]+)>/#\1/g')
169  echo "$text"
170}
171
172format_ts() {
173  local ts="$1"
174  local unix_ts="${ts%%.*}"
175  date -d "@$unix_ts" "+%Y-%m-%d %H:%M" 2>/dev/null || echo "$ts"
176}
177
178format_message() {
179  local msg="$1"
180  local indent="${2:-}"
181  local user_id ts text reply_count
182  user_id=$(echo "$msg" | jq -r '.user // "unknown"')
183  ts=$(echo "$msg" | jq -r '.ts')
184  text=$(echo "$msg" | jq -r '.text // ""')
185  reply_count=$(echo "$msg" | jq -r '.reply_count // 0')
186
187  local username
188  username=$(resolve_user "$user_id")
189  local time_str
190  time_str=$(format_ts "$ts")
191  local clean
192  clean=$(clean_text "$text")
193
194  local reply_indicator=""
195  [[ "$reply_count" -gt 0 ]] && reply_indicator=" [${reply_count} replies]"
196
197  echo "${indent}${username} [${time_str}]:${reply_indicator} ${clean}"
198}
199
200# --- Slackdump helpers ---
201
202ws_flags() {
203  local sd_name="${SLACKDUMP_WORKSPACE_NAMES[$WORKSPACE]:-$WORKSPACE}"
204  [[ -n "$sd_name" ]] && echo "-workspace" "$sd_name"
205}
206
207check_slackdump() {
208  command -v "$SLACKDUMP" &>/dev/null || die "slackdump not found. Install: nix profile install nixpkgs#slackdump"
209}
210
211# --- Commands ---
212
213cmd_help() {
214  cat <<'EOF'
215SlackRead.sh: Read-only Slack CLI
216
217Usage: SlackRead.sh [--workspace NAME] <command> [options]
218
219Global options:
220  --workspace, -w NAME    Target workspace (default: redhat)
221                          Known: redhat (curl), tekton (slackdump)
222
223Commands:
224  auth                    Check authentication status
225  channels [options]      List channels
226  history <channel> [options]  Read channel messages
227  thread <channel> <ts|URL>    Read a thread
228  search <query>          Search messages
229  users [query]           List or search users
230
231History options:
232  --since YYYY-MM-DD      Messages since date (default: 7 days ago)
233  --limit N               Max messages (default: 50, curl only)
234
235Examples:
236  SlackRead.sh auth
237  SlackRead.sh channels --member-only
238  SlackRead.sh history team-ocp-pipeline
239  SlackRead.sh history team-ocp-pipeline --since 2026-03-25
240  SlackRead.sh -w tekton history pipeline-dev
241  SlackRead.sh thread https://redhat.enterprise.slack.com/archives/C123/p456
242  SlackRead.sh search "release 1.22"
243  SlackRead.sh users vincent
244EOF
245}
246
247cmd_auth() {
248  if [[ "$BACKEND" == "curl" ]]; then
249    echo "Workspace: $WORKSPACE (curl backend)"
250    echo "Token source: passage slack/$WORKSPACE/token"
251    echo "Cookie source: passage slack/$WORKSPACE/cookie"
252    echo "---"
253    local result
254    result=$(slack_api "auth.test")
255    echo "$result" | jq -r '"Status: OK\nUser: \(.user)\nTeam: \(.team)\nEnterprise: \(.enterprise_id // "n/a")"'
256  else
257    check_slackdump
258    echo "Workspace: $WORKSPACE (slackdump backend)"
259    "$SLACKDUMP" workspace list -a 2>/dev/null || die "No workspace configured"
260  fi
261}
262
263cmd_channels() {
264  local member_only=false
265  local include_all=false
266  while [[ $# -gt 0 ]]; do
267    case "$1" in
268      --member-only|-m) member_only=true; shift ;;
269      --all|-a) include_all=true; shift ;;
270      *) shift ;;
271    esac
272  done
273
274  if [[ "$BACKEND" == "curl" ]]; then
275    # Enterprise Grid blocks conversations.list
276    # Suggest using search to discover channels instead
277    echo "Note: Enterprise Grid restricts channel listing."
278    echo "Use 'SlackRead.sh search \"in:#channel-name\"' to find channels."
279    echo "Or provide the channel ID directly to history/thread commands."
280    echo ""
281    echo "Common channels (from search history):"
282    # Show any channels we've seen in search results from cache
283    if [[ -f "$CACHE_DIR/known_channels.json" ]]; then
284      jq -r 'to_entries[] | "\(.key)\t#\(.value)"' "$CACHE_DIR/known_channels.json" 2>/dev/null | sort -t$'\t' -k2 | column -t -s$'\t'
285    else
286      echo "(none cached yet — run a search first)"
287    fi
288  else
289    check_slackdump
290    local flags="-no-json -format text -chan-types public_channel,private_channel"
291    [[ "$member_only" == "true" ]] && flags="$flags -member-only"
292    [[ "$include_all" == "true" ]] && flags="$flags -chan-types public_channel,private_channel,mpim,im"
293    "$SLACKDUMP" list channels $flags $(ws_flags) -y 2>/dev/null | grep -v '^\[' | grep -v '^$'
294  fi
295}
296
297cmd_history() {
298  local channel="${1:?Usage: SlackRead.sh history <channel> [--since DATE] [--limit N]}"
299  shift
300  local since="" limit=50
301  since=$(date -d "7 days ago" "+%Y-%m-%d" 2>/dev/null || echo "")
302
303  while [[ $# -gt 0 ]]; do
304    case "$1" in
305      --since|-s) since="$2"; shift 2 ;;
306      --limit|-l) limit="$2"; shift 2 ;;
307      *) shift ;;
308    esac
309  done
310
311  ensure_user_cache
312
313  if [[ "$BACKEND" == "curl" ]]; then
314    curl_history "$channel" "$since" "$limit"
315  else
316    slackdump_history "$channel" "$since"
317  fi
318}
319
320curl_resolve_channel() {
321  local channel="$1"
322  # If already a channel ID, return as-is
323  [[ "$channel" =~ ^[CDG][A-Z0-9]+$ ]] && { echo "$channel"; return; }
324
325  # Use search to discover channel ID from name
326  local response
327  response=$(slack_api "search.messages" \
328    -d "query=in:#${channel}" \
329    -d "count=1")
330  local chan_id
331  chan_id=$(echo "$response" | jq -r '.messages.matches[0].channel.id // ""')
332  if [[ -n "$chan_id" ]]; then
333    echo "$chan_id"
334  else
335    die "Channel '#${channel}' not found. Try using the channel ID directly."
336  fi
337}
338
339curl_history() {
340  local channel="$1" since="$2" limit="$3"
341  local channel_name="$channel"
342
343  # Resolve channel name to ID
344  local channel_id
345  channel_id=$(curl_resolve_channel "$channel")
346
347  # Convert since date to unix timestamp for oldest param
348  local oldest=""
349  if [[ -n "$since" ]]; then
350    oldest=$(date -d "$since" "+%s" 2>/dev/null || echo "")
351  fi
352
353  # Fetch via conversations.history (works on Enterprise Grid with channel ID)
354  local args=(-d "channel=$channel_id" -d "limit=$limit")
355  [[ -n "$oldest" ]] && args+=(-d "oldest=$oldest")
356
357  local response
358  response=$(slack_api "conversations.history" "${args[@]}")
359
360  local msg_count
361  msg_count=$(echo "$response" | jq '.messages | length')
362  echo "#${channel_name}${msg_count} messages"
363  echo "---"
364
365  # Messages come newest-first, reverse for chronological order
366  echo "$response" | jq -c '.messages | reverse | .[]' 2>/dev/null | while IFS= read -r msg; do
367    local user_id username ts text reply_count
368    user_id=$(echo "$msg" | jq -r '.user // "unknown"')
369    username=$(echo "$msg" | jq -r '.user // "unknown"')
370    ts=$(echo "$msg" | jq -r '.ts')
371    text=$(echo "$msg" | jq -r '.text // ""')
372    reply_count=$(echo "$msg" | jq -r '.reply_count // 0')
373
374    # Resolve user - try cache, fallback to user_id
375    local display_name
376    display_name=$(resolve_user "$user_id")
377
378    local time_str
379    time_str=$(format_ts "$ts")
380    local clean
381    clean=$(clean_text "$text")
382
383    local reply_indicator=""
384    [[ "$reply_count" -gt 0 ]] && reply_indicator=" [${reply_count} replies]"
385
386    echo "${display_name} [${time_str}]:${reply_indicator} ${clean}"
387  done
388}
389
390slackdump_history() {
391  local channel="$1" since="$2"
392  check_slackdump
393
394  # Resolve channel name to ID if needed
395  if [[ ! "$channel" =~ ^[CDG] ]]; then
396    local resolved
397    resolved=$("$SLACKDUMP" list channels -no-json -format text -b $(ws_flags) -y 2>/dev/null \
398      | grep -i "#${channel}" | head -1 | awk '{print $1}')
399    [[ -n "$resolved" ]] && channel="$resolved" || die "Channel '${channel}' not found."
400  fi
401
402  local tmpdir
403  tmpdir=$(mktemp -d /tmp/slack-read-XXXXXX)
404  trap "rm -rf '$tmpdir'" EXIT
405
406  local dump_args=(-o "$tmpdir/dump.zip" -y)
407  [[ -n "$since" ]] && dump_args+=(-time-from "${since}T00:00:00")
408
409  "$SLACKDUMP" dump $(ws_flags) "${dump_args[@]}" "$channel" 2>/dev/null
410
411  if [[ -f "$tmpdir/dump.zip" ]]; then
412    local json_file
413    json_file=$(unzip -l "$tmpdir/dump.zip" 2>/dev/null | grep '\.json$' | awk '{print $NF}' | head -1)
414    if [[ -n "$json_file" ]]; then
415      unzip -p "$tmpdir/dump.zip" "$json_file" > "$tmpdir/messages.json"
416      local channel_name msg_count
417      channel_name=$(jq -r '.name // .channel_id // "unknown"' "$tmpdir/messages.json")
418      msg_count=$(jq '.messages | length' "$tmpdir/messages.json")
419
420      if [[ "$msg_count" -eq 0 ]]; then
421        echo "No messages found in #${channel_name}"
422        return
423      fi
424
425      echo "#${channel_name}${msg_count} messages"
426      echo "---"
427
428      jq -c '.messages[]' "$tmpdir/messages.json" | while IFS= read -r msg; do
429        format_message "$msg"
430        # Thread replies inline
431        local replies
432        replies=$(echo "$msg" | jq -c '.slackdump_thread_replies // [] | .[1:]')
433        if [[ "$replies" != "[]" ]]; then
434          echo "$replies" | jq -c '.[]' | while IFS= read -r reply; do
435            format_message "$reply" "  ↳ "
436          done
437        fi
438      done
439    else
440      echo "No messages found."
441    fi
442  else
443    die "Failed to dump channel $channel"
444  fi
445}
446
447cmd_thread() {
448  local input="${1:?Usage: SlackRead.sh thread <channel> <ts> OR SlackRead.sh thread <URL>}"
449  shift
450  local channel="" thread_ts=""
451
452  # Parse Slack URL
453  if [[ "$input" =~ ^https:// ]]; then
454    channel=$(echo "$input" | sed -E 's|.*/archives/([^/]+)/.*|\1|')
455    local raw_ts
456    raw_ts=$(echo "$input" | sed -E 's|.*/p([0-9]+)$|\1|')
457    thread_ts="${raw_ts:0:10}.${raw_ts:10}"
458  else
459    channel="$input"
460    thread_ts="${1:?Usage: SlackRead.sh thread <channel> <ts>}"
461    shift || true
462  fi
463
464  ensure_user_cache
465
466  if [[ "$BACKEND" == "curl" ]]; then
467    local response
468    response=$(slack_api "conversations.replies" \
469      -d "channel=$channel" \
470      -d "ts=$thread_ts" \
471      -d "limit=100")
472
473    local msg_count
474    msg_count=$(echo "$response" | jq '.messages | length')
475    echo "Thread — ${msg_count} messages"
476    echo "---"
477
478    echo "$response" | jq -c '.messages[]' | while IFS= read -r msg; do
479      format_message "$msg"
480    done
481  else
482    check_slackdump
483    local tmpdir
484    tmpdir=$(mktemp -d /tmp/slack-read-XXXXXX)
485    trap "rm -rf '$tmpdir'" EXIT
486
487    "$SLACKDUMP" dump $(ws_flags) -o "$tmpdir/dump.zip" -y "${channel}:${thread_ts}" 2>/dev/null
488
489    if [[ -f "$tmpdir/dump.zip" ]]; then
490      local json_file
491      json_file=$(unzip -l "$tmpdir/dump.zip" 2>/dev/null | grep '\.json$' | awk '{print $NF}' | head -1)
492      if [[ -n "$json_file" ]]; then
493        unzip -p "$tmpdir/dump.zip" "$json_file" > "$tmpdir/thread.json"
494        local msg_count
495        msg_count=$(jq '.messages | length' "$tmpdir/thread.json")
496        echo "Thread — ${msg_count} messages"
497        echo "---"
498        jq -c '.messages[]' "$tmpdir/thread.json" | while IFS= read -r msg; do
499          format_message "$msg"
500        done
501      fi
502    fi
503  fi
504}
505
506cmd_search() {
507  local query="${1:?Usage: SlackRead.sh search <query>}"
508  shift
509
510  ensure_user_cache
511
512  if [[ "$BACKEND" == "curl" ]]; then
513    local response
514    response=$(slack_api "search.messages" \
515      -d "query=$query" \
516      -d "sort=timestamp" \
517      -d "sort_dir=desc" \
518      -d "count=20")
519
520    local total
521    total=$(echo "$response" | jq '.messages.total // 0')
522    echo "Search: \"${query}\" — ${total} results (showing up to 20)"
523    echo "---"
524
525    # Cache discovered channels and users from results
526    echo "$response" | jq -r '.messages.matches[] | "\(.channel.id)\t\(.channel.name)"' 2>/dev/null \
527      | sort -u | while IFS=$'\t' read -r cid cname; do
528        [[ -n "$cid" && -n "$cname" ]] && {
529          local known="${CACHE_DIR}/known_channels.json"
530          if [[ -f "$known" ]]; then
531            jq --arg id "$cid" --arg name "$cname" '. + {($id): $name}' "$known" > "$known.tmp" && mv "$known.tmp" "$known"
532          else
533            echo "{\"$cid\": \"$cname\"}" > "$known"
534          fi
535        }
536      done 2>/dev/null
537
538    echo "$response" | jq -r '.messages.matches[] | "\(.user // "")\t\(.username // "")"' 2>/dev/null \
539      | sort -u | while IFS=$'\t' read -r uid uname; do
540        cache_user_from_result "$uid" "$uname"
541      done 2>/dev/null
542
543    echo "$response" | jq -c '.messages.matches[]' 2>/dev/null | while IFS= read -r msg; do
544      local user_id ts text chan
545      user_id=$(echo "$msg" | jq -r '.user // .username // "unknown"')
546      ts=$(echo "$msg" | jq -r '.ts')
547      text=$(echo "$msg" | jq -r '.text // "" | gsub("\n"; " ") | .[0:300]')
548      chan=$(echo "$msg" | jq -r '.channel.name // "unknown"')
549
550      local username
551      username=$(echo "$msg" | jq -r '.username // ""')
552      [[ -z "$username" ]] && username=$(resolve_user "$user_id")
553      local time_str
554      time_str=$(format_ts "$ts")
555      local clean
556      clean=$(clean_text "$text")
557
558      echo "${username} [${time_str}] in #${chan}: ${clean}"
559    done
560  else
561    check_slackdump
562    local tmpdir
563    tmpdir=$(mktemp -d /tmp/slack-read-XXXXXX)
564    trap "rm -rf '$tmpdir'" EXIT
565
566    "$SLACKDUMP" search messages $(ws_flags) -o "$tmpdir" -y "$query" 2>/dev/null 1>/dev/null
567
568    if [[ -f "$tmpdir/slackdump.sqlite" ]]; then
569      local count
570      count=$(sqlite3 "$tmpdir/slackdump.sqlite" "SELECT count(*) FROM SEARCH_MESSAGE;" 2>/dev/null || echo 0)
571      echo "Search: \"${query}\" — ${count} results"
572      echo "---"
573
574      sqlite3 "$tmpdir/slackdump.sqlite" \
575        "SELECT CAST(DATA as TEXT) FROM SEARCH_MESSAGE ORDER BY TS DESC LIMIT 20;" 2>/dev/null \
576        | jq -r --slurpfile users "$USER_CACHE" '
577          ($users[0][.user] // .username // .user // "unknown") as $username |
578          (.channel.name // "unknown") as $chan |
579          (.ts // "0") as $ts |
580          (.text // "" | gsub("\n"; " ") | ltrimstr(" ") | .[0:300]) as $text |
581          "\($username)\t\($ts)\t\($chan)\t\($text)"
582        ' 2>/dev/null | while IFS=$'\t' read -r username ts chan text; do
583          [[ -z "$username" ]] && continue
584          local time_str
585          time_str=$(format_ts "$ts")
586          local clean
587          clean=$(clean_text "$text")
588          echo "${username} [${time_str}] in #${chan}: ${clean}"
589        done
590    else
591      echo "No results found."
592    fi
593  fi
594}
595
596cmd_users() {
597  local query="${1:-}"
598
599  if [[ "$BACKEND" == "curl" ]]; then
600    if [[ -n "$query" ]]; then
601      # Search for user via messages they've sent
602      local response
603      response=$(slack_api "search.messages" \
604        -d "query=from:@${query}" \
605        -d "count=5" \
606        -d "sort=timestamp" \
607        -d "sort_dir=desc")
608      echo "$response" | jq -r '.messages.matches[] | "\(.username // .user)\t\(.user)"' 2>/dev/null | sort -u | column -t -s$'\t'
609    else
610      # Show cached users
611      ensure_user_cache
612      if [[ -s "$USER_CACHE" ]] && [[ "$(cat "$USER_CACHE")" != "{}" ]]; then
613        jq -r 'to_entries[] | "\(.value)\t\(.key)"' "$USER_CACHE" | column -t -s$'\t' | head -50
614      else
615        echo "User cache is empty. Users are discovered incrementally from search results."
616        echo "Use: SlackRead.sh users <name> to search for a specific user."
617      fi
618    fi
619  else
620    check_slackdump
621    local users_output
622    users_output=$("$SLACKDUMP" list users -no-json -format text $(ws_flags) -y 2>&1 \
623      | grep -v '^\[' | grep -v '^$' | grep -v 'user cache')
624    if [[ -n "$query" ]]; then
625      echo "$users_output" | grep -i "$query"
626    else
627      echo "$users_output" | head -50
628    fi
629  fi
630}
631
632# --- Main ---
633cmd="${1:-help}"
634shift || true
635
636case "$cmd" in
637  auth)     cmd_auth "$@" ;;
638  channels) cmd_channels "$@" ;;
639  history)  cmd_history "$@" ;;
640  thread)   cmd_thread "$@" ;;
641  search)   cmd_search "$@" ;;
642  users)    cmd_users "$@" ;;
643  help|--help|-h) cmd_help ;;
644  *) die "Unknown command: $cmd. Run 'SlackRead.sh help' for usage." ;;
645esac