Commit df11be2d9247
Changed files (8)
dots
agents
skills
slack
tools
pkgs
tools
slack-archive
dots/agents/skills/slack/tools/slack-read.sh
@@ -1,645 +0,0 @@
-#!/usr/bin/env bash
-# shellcheck disable=SC2046,SC2064,SC2086,SC2015
-# slack-read.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/agents/skills/slack/SKILL.md
@@ -1,191 +0,0 @@
----
-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)
pkgs/default.nix
@@ -33,7 +33,6 @@ in
jellyfin-manage-playlist = pkgs.callPackage ../tools/jellyfin-manage-playlist { };
music-playlist-dl = pkgs.callPackage ../tools/music-playlist-dl { };
readwise-reader = pkgs.callPackage ../tools/readwise-reader { };
- slack-archive = pkgs.callPackage ../tools/slack-archive { };
gcal-to-org = pkgs.callPackage ../tools/gcal-to-org { };
daily-plan = pkgs.callPackage ../tools/daily-plan { };
github-notif-manager = pkgs.callPackage ../tools/github-notif-manager { };
systems/kyushu/extra.nix
@@ -177,57 +177,4 @@ in
# Make sure we don't start docker until required
systemd.services.docker.wantedBy = lib.mkForce [ ];
-
- # Slack Archive - daily backup of public Slack channels
- systemd.tmpfiles.rules = [
- "d /var/lib/slack-archive 0750 vincent users -"
- ];
-
- systemd.services.slack-archive = {
- description = "Slack Public Channel Archiver";
- after = [ "network-online.target" ];
- wants = [ "network-online.target" ];
-
- serviceConfig = {
- Type = "oneshot";
- User = "vincent";
- Group = "users";
- ExecStart = "${pkgs.slack-archive}/bin/slack-archive archive";
- Environment = [
- "SLACK_ARCHIVE_DIR=/var/lib/slack-archive"
- "SLACK_ARCHIVE_HTML_DIR=/home/vincent/src/experiments/tektoncd-slack-archive"
- "HOME=/home/vincent"
- "XDG_CACHE_HOME=/home/vincent/.local/cache"
- ];
-
- # Security hardening
- PrivateTmp = true;
- ProtectSystem = "strict";
- ProtectHome = "read-only";
- ReadWritePaths = [
- "/var/lib/slack-archive"
- "/home/vincent/.local/cache/slackdump"
- "/home/vincent/.local/cache/uv"
- "/home/vincent/.local/share/uv"
- "/home/vincent/src/experiments/tektoncd-slack-archive"
- ];
- NoNewPrivileges = true;
-
- # Logging
- StandardOutput = "journal";
- StandardError = "journal";
- SyslogIdentifier = "slack-archive";
- };
- };
-
- systemd.timers.slack-archive = {
- description = "Daily Slack Archive Timer";
- wantedBy = [ "timers.target" ];
-
- timerConfig = {
- OnCalendar = "09:00"; # Run at 9 AM daily (system should be fully up by then)
- RandomizedDelaySec = 1800; # 0-30 min random delay
- Persistent = true;
- };
- };
}
systems/kyushu/home.nix
@@ -78,7 +78,6 @@ in
shpool-remote
cliphist-cleanup
- slackdump
tekton-lsp
pi-acp
systems/okinawa/home.nix
@@ -119,8 +119,6 @@ in
shpool-remote
cliphist-cleanup
- slackdump
-
ssh-to-age
jayrah
tools/slack-archive/default.nix
@@ -1,48 +0,0 @@
-{
- lib,
- stdenv,
- makeWrapper,
- slackdump,
- jq,
- findutils,
- gnused,
- uv,
- python3,
-}:
-
-stdenv.mkDerivation {
- pname = "slack-archive";
- version = "0.1.0";
-
- src = ./.;
-
- nativeBuildInputs = [ makeWrapper ];
-
- installPhase = ''
- runHook preInstall
-
- mkdir -p $out/bin
- cp slack-archive.sh $out/bin/slack-archive
- chmod +x $out/bin/slack-archive
-
- wrapProgram $out/bin/slack-archive \
- --prefix PATH : ${
- lib.makeBinPath [
- slackdump
- jq
- findutils
- gnused
- uv
- python3
- ]
- }
-
- runHook postInstall
- '';
-
- meta = with lib; {
- description = "Slack public channel archiver with static HTML export";
- license = licenses.mit;
- platforms = platforms.linux;
- };
-}
tools/slack-archive/slack-archive.sh
@@ -1,265 +0,0 @@
-#!/usr/bin/env bash
-# Slack Public Channels Archive Script
-# Archives public channels incrementally and generates static HTML viewer
-set -euo pipefail
-
-# --- Configuration ---
-DATA_DIR="${SLACK_ARCHIVE_DIR:-/var/lib/slack-archive}"
-ARCHIVE_DIR="$DATA_DIR/archive"
-EXPORT_DIR="$DATA_DIR/exports"
-HTML_DIR="${SLACK_ARCHIVE_HTML_DIR:-$DATA_DIR/html}"
-CHANNELS_FILE="$DATA_DIR/public-channels.txt"
-CHANNELS_JSON="$DATA_DIR/channels.json"
-
-# How often to refresh channel list (in days)
-CHANNEL_REFRESH_DAYS="${CHANNEL_REFRESH_DAYS:-7}"
-
-# --- Functions ---
-
-log() {
- echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
-}
-
-die() {
- log "ERROR: $*" >&2
- exit 1
-}
-
-check_auth() {
- # Check for cached credentials
- local cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/slackdump"
- if [[ -f "$cache_dir/provider.bin" ]]; then
- log "Using cached credentials from $cache_dir"
- return 0
- fi
-
- die "No cached credentials found. Run 'slackdump login' interactively first."
-}
-
-setup_dirs() {
- mkdir -p "$DATA_DIR" "$EXPORT_DIR" "$HTML_DIR"
-}
-
-refresh_channel_list() {
- local should_refresh=false
-
- if [[ ! -f "$CHANNELS_FILE" ]]; then
- log "Channel list not found, fetching..."
- should_refresh=true
- elif [[ -n "$(find "$CHANNELS_FILE" -mtime +"$CHANNEL_REFRESH_DAYS" 2>/dev/null)" ]]; then
- log "Channel list older than $CHANNEL_REFRESH_DAYS days, refreshing..."
- should_refresh=true
- fi
-
- if [[ "$should_refresh" == "true" ]] || [[ "${FORCE_REFRESH:-}" == "true" ]]; then
- log "Fetching channel list from Slack..."
- slackdump list channels -format JSON -no-json > "$CHANNELS_JSON"
-
- # Filter to public channels only (exclude private, DMs, group DMs)
- jq -r '.[] | select(.is_private == false and .is_im == false and .is_mpim == false) | .id' \
- "$CHANNELS_JSON" > "$CHANNELS_FILE"
-
- local count
- count=$(wc -l < "$CHANNELS_FILE")
- log "Found $count public channels"
- else
- log "Using cached channel list ($(wc -l < "$CHANNELS_FILE") channels)"
- fi
-}
-
-run_archive() {
- if [[ -d "$ARCHIVE_DIR" ]]; then
- log "Resuming archive from previous state..."
- slackdump resume "$ARCHIVE_DIR"
- else
- log "Starting fresh archive..."
- slackdump archive -o "$ARCHIVE_DIR" @"$CHANNELS_FILE"
- fi
-}
-
-convert_to_export() {
- EXPORT_FILE="$EXPORT_DIR/slack-export-$(date +%Y-%m-%d).zip"
- log "Converting archive to export format: $EXPORT_FILE"
- slackdump convert -f export -storage standard -o "$EXPORT_FILE" "$ARCHIVE_DIR"
-}
-
-generate_html() {
- local export_file="$1"
- log "Generating HTML viewer..."
- uvx slack-export-viewer -z "$export_file" --html-only -o "$HTML_DIR"
-
- # Fix empty anchor tags (slack-export-viewer bug)
- # Converts <a href='URL'></a> to <a href='URL'>URL</a>
- log "Fixing empty links..."
- find "$HTML_DIR" -name "*.html" -exec sed -i -E "s|<a href='([^']+)'></a>|<a href='\\1'>\\1</a>|g" {} \;
-
- # Reverse message order (newest first)
- log "Reversing message order (newest first)..."
- find "$HTML_DIR" -name "*.html" -exec python3 -c '
-import sys
-from pathlib import Path
-from html.parser import HTMLParser
-
-class MessageReverser(HTMLParser):
- def __init__(self):
- super().__init__()
- self.output = []
- self.in_messages = False
- self.messages_depth = 0
- self.message_containers = []
- self.current_container = []
- self.in_container = False
- self.container_depth = 0
-
- def handle_starttag(self, tag, attrs):
- attrs_dict = dict(attrs)
- class_attr = attrs_dict.get("class", "")
-
- raw = self.get_starttag_text()
-
- if tag == "div" and "messages" in class_attr.split():
- self.in_messages = True
- self.messages_depth = 1
- self.output.append(raw)
- return
-
- if self.in_messages and not self.in_container:
- if tag == "div" and "message-container" in class_attr:
- self.in_container = True
- self.container_depth = 1
- self.current_container = [raw]
- return
- elif tag == "div":
- self.messages_depth += 1
-
- if self.in_container:
- self.current_container.append(raw)
- if tag == "div":
- self.container_depth += 1
- else:
- self.output.append(raw)
-
- def handle_endtag(self, tag):
- raw = f"</{tag}>"
-
- if self.in_container:
- if tag == "div":
- self.container_depth -= 1
- self.current_container.append(raw)
- if self.container_depth == 0:
- self.message_containers.append("".join(self.current_container))
- self.current_container = []
- self.in_container = False
- elif self.in_messages:
- if tag == "div":
- self.messages_depth -= 1
- if self.messages_depth == 0:
- # Flush reversed containers
- for container in reversed(self.message_containers):
- self.output.append(container)
- self.message_containers = []
- self.in_messages = False
- self.output.append(raw)
- else:
- self.output.append(raw)
-
- def handle_data(self, data):
- if self.in_container:
- self.current_container.append(data)
- else:
- self.output.append(data)
-
- def handle_entityref(self, name):
- raw = f"&{name};"
- if self.in_container:
- self.current_container.append(raw)
- else:
- self.output.append(raw)
-
- def handle_charref(self, name):
- raw = f"&#{name};"
- if self.in_container:
- self.current_container.append(raw)
- else:
- self.output.append(raw)
-
- def handle_comment(self, data):
- raw = f"<!--{data}-->"
- if self.in_container:
- self.current_container.append(raw)
- else:
- self.output.append(raw)
-
- def handle_decl(self, decl):
- self.output.append(f"<!{decl}>")
-
- def get_result(self):
- return "".join(self.output)
-
-path = Path(sys.argv[1])
-content = path.read_text()
-parser = MessageReverser()
-parser.feed(content)
-path.write_text(parser.get_result())
-' {} \;
-
- log "HTML generated at: $HTML_DIR"
-}
-
-serve_html() {
- local port="${1:-8080}"
- log "Starting server at http://localhost:$port"
- python3 -m http.server -d "$HTML_DIR" "$port"
-}
-
-# --- Main ---
-
-main() {
- local cmd="${1:-archive}"
-
- case "$cmd" in
- archive)
- check_auth
- setup_dirs
- refresh_channel_list
- run_archive
- convert_to_export
- generate_html "$EXPORT_FILE"
- log "Done! View at: $HTML_DIR/index.html"
- ;;
- channels)
- check_auth
- setup_dirs
- FORCE_REFRESH=true refresh_channel_list
- ;;
- html)
- local latest_export
- latest_export=$(find "$EXPORT_DIR" -maxdepth 1 -name "*.zip" -type f -printf '%T@ %p\n' 2>/dev/null | sort -rn | head -1 | cut -d' ' -f2-)
- if [[ -z "$latest_export" ]]; then
- die "No export found. Run '$0 archive' first."
- fi
- generate_html "$latest_export"
- ;;
- serve)
- serve_html "${2:-8080}"
- ;;
- help|--help|-h)
- echo "Usage: $0 [command]"
- echo ""
- echo "Commands:"
- echo " archive Fetch channels, archive, convert, generate HTML (default)"
- echo " channels Refresh channel list only"
- echo " html Regenerate HTML from latest export"
- echo " serve Start local HTTP server (port 8080 or specify)"
- echo " help Show this help"
- echo ""
- echo "Environment:"
- echo " SLACK_ARCHIVE_DIR Data directory (default: /var/lib/slack-archive)"
- ;;
- *)
- die "Unknown command: $cmd. Run '$0 help' for usage."
- ;;
- esac
-}
-
-main "$@"