@alankyshum/slack-cli 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +3 -0
  2. package/bin/slack-cli +130 -37
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,3 +1,6 @@
1
+ [![npm version](https://img.shields.io/npm/v/@alankyshum/slack-cli)](https://www.npmjs.com/package/@alankyshum/slack-cli)
2
+ [![PyPI version](https://img.shields.io/pypi/v/slack-cli-agent)](https://pypi.org/project/slack-cli-agent/)
3
+
1
4
  # slack-cli
2
5
 
3
6
  A Slack CLI designed for coding agents (Claude Code, Cursor, etc.) that provides structured JSON access to Slack workspaces via the Web API.
package/bin/slack-cli CHANGED
@@ -7,7 +7,7 @@
7
7
  # slack-cli auth [--domain <workspace>.slack.com] - Extract tokens from browser
8
8
  # slack-cli search <query> - Search messages (Slack query syntax)
9
9
  # slack-cli read <channel> [n] - Read messages from a channel
10
- # slack-cli draft <channel> <msg> - Save a draft locally (does NOT send)
10
+ # slack-cli draft <channel> <msg> - Save a draft to Slack (syncs across devices)
11
11
  # slack-cli send <draft_id> - Send a draft (with confirmation)
12
12
  # slack-cli help - Show full help
13
13
 
@@ -15,7 +15,6 @@ set -euo pipefail
15
15
 
16
16
  CONFIG_DIR="${SLACK_CLI_HOME:-$HOME/.slack-cli}"
17
17
  CREDS_FILE="$CONFIG_DIR/credentials.json"
18
- DRAFTS_FILE="$CONFIG_DIR/drafts.json"
19
18
  SLACK_API="https://slack.com/api"
20
19
 
21
20
  # ── Helpers ──────────────────────────────────────────────────────────────────
@@ -36,6 +35,13 @@ _check_creds() {
36
35
  _token() { jq -r .token "$CREDS_FILE"; }
37
36
  _cookie() { jq -r .d_cookie "$CREDS_FILE"; }
38
37
  _domain() { jq -r '.domain // empty' "$CREDS_FILE"; }
38
+ _workspace_api() {
39
+ # Enterprise Grid uses workspace-specific URL for drafts API
40
+ local domain
41
+ domain=$(_domain)
42
+ local workspace="${domain%%.*}"
43
+ echo "https://grid-${workspace}.enterprise.slack.com/api"
44
+ }
39
45
 
40
46
  _api() {
41
47
  local method="$1"; shift
@@ -57,12 +63,46 @@ _api() {
57
63
  echo "$response"
58
64
  }
59
65
 
66
+ _api_workspace() {
67
+ # Multipart form API call to workspace-specific URL (matches Slack client behavior)
68
+ local method="$1"; shift
69
+ # Remaining args are -F field pairs
70
+ local response
71
+ response=$(curl -s "$(_workspace_api)/$method" \
72
+ -H "Cookie: d=$(_cookie)" \
73
+ -F "token=$(_token)" \
74
+ "$@")
75
+
76
+ local ok err
77
+ ok=$(echo "$response" | jq -r '.ok')
78
+ if [[ "$ok" != "true" ]]; then
79
+ err=$(echo "$response" | jq -r '.error // empty')
80
+ if [[ "$err" == "token_revoked" || "$err" == "invalid_auth" || "$err" == "not_authed" ]]; then
81
+ echo '{"ok":false,"error":"Token expired. Run: slack-cli auth --domain '"$(_domain)"'"}' | jq .
82
+ exit 1
83
+ fi
84
+ fi
85
+ echo "$response"
86
+ }
87
+
60
88
  _urlencode() {
61
89
  python3 -c "import sys,urllib.parse; print(urllib.parse.quote(sys.stdin.read().strip()))" 2>/dev/null
62
90
  }
63
91
 
64
92
  _resolve_channel() {
65
93
  local name="${1#\#}"
94
+ name="${name#@}"
95
+
96
+ # User ID → open a DM conversation to get channel ID
97
+ if [[ "$name" =~ ^U[A-Z0-9]+$ ]]; then
98
+ local response channel_id
99
+ response=$(_api conversations.open "users=$name")
100
+ channel_id=$(echo "$response" | jq -r '.channel.id // empty')
101
+ echo "$channel_id"
102
+ return
103
+ fi
104
+
105
+ # Otherwise search for channel by name
66
106
  local response channel_id
67
107
  response=$(_api search.messages "query=in:%23${name}&count=1")
68
108
  channel_id=$(echo "$response" | jq -r '.messages.matches[0]?.channel.id // empty')
@@ -359,7 +399,15 @@ cmd_draft() {
359
399
  fi
360
400
 
361
401
  local channel_id="$channel"
362
- if [[ ! "$channel" =~ ^[CDG][A-Z0-9]+$ ]]; then
402
+ if [[ "$channel" =~ ^[CDG][A-Z0-9]+$ ]]; then
403
+ : # Already a channel ID
404
+ elif [[ "$channel" =~ ^U[A-Z0-9]+$ ]]; then
405
+ channel_id=$(_resolve_channel "$channel")
406
+ if [[ -z "$channel_id" || "$channel_id" == "null" ]]; then
407
+ echo "{\"ok\":false,\"error\":\"Could not open DM with user: $channel\"}" | jq .
408
+ return 1
409
+ fi
410
+ else
363
411
  channel_id=$(_resolve_channel "$channel")
364
412
  if [[ -z "$channel_id" || "$channel_id" == "null" ]]; then
365
413
  echo "{\"ok\":false,\"error\":\"Could not resolve channel: $channel\"}" | jq .
@@ -367,35 +415,66 @@ cmd_draft() {
367
415
  fi
368
416
  fi
369
417
 
370
- mkdir -p "$CONFIG_DIR"
371
- local draft_id
372
- draft_id=$(date +%s)
373
- local draft_entry
374
- draft_entry=$(jq -n \
375
- --arg id "$draft_id" \
376
- --arg channel "$channel" \
377
- --arg channel_id "$channel_id" \
378
- --arg message "$message" \
379
- --arg created "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
380
- '{id:$id, channel:$channel, channel_id:$channel_id, message:$message, created:$created, status:"draft"}')
381
-
382
- if [[ -f "$DRAFTS_FILE" ]]; then
383
- local existing
384
- existing=$(cat "$DRAFTS_FILE")
385
- echo "$existing" | jq --argjson new "$draft_entry" '. + [$new]' > "$DRAFTS_FILE"
418
+ # Create server-side draft via Slack API (syncs across devices)
419
+ local client_msg_id
420
+ client_msg_id=$(python3 -c "import uuid; print(str(uuid.uuid4()))")
421
+
422
+ local blocks
423
+ blocks=$(jq -n --arg text "$message" \
424
+ '[{type:"rich_text", elements:[{type:"rich_text_section", elements:[{type:"text", text:$text}]}]}]')
425
+ local destinations
426
+ destinations=$(jq -n --arg ch "$channel_id" '[{channel_id:$ch}]')
427
+
428
+ local response
429
+ response=$(_api_workspace drafts.create \
430
+ -F "blocks=$blocks" \
431
+ -F "client_msg_id=$client_msg_id" \
432
+ -F "attachments=" \
433
+ -F "destinations=$destinations" \
434
+ -F "file_ids=[]" \
435
+ -F "is_from_composer=false" \
436
+ -F "_x_reason=MessageInput:updateDraft" \
437
+ -F "_x_mode=online" \
438
+ -F "_x_sonic=true" \
439
+ -F "_x_app_name=client")
440
+
441
+ local ok
442
+ ok=$(echo "$response" | jq -r '.ok')
443
+ if [[ "$ok" == "true" ]]; then
444
+ echo "$response" | jq '{ok, action:"draft_saved", info:"Draft saved to Slack (syncs across devices). Use slack-cli drafts to list, slack-cli send <id> to send.", draft: {id:.draft.id, channel_id:.draft.destinations[0].channel_id, text:([.draft.blocks[]?.elements[]?.elements[]? | select(.type == "text") | .text] | join("")), created:.draft.date_created}}'
386
445
  else
387
- echo "[$draft_entry]" | jq . > "$DRAFTS_FILE"
446
+ echo "$response" | jq '{ok, error}'
447
+ return 1
388
448
  fi
389
-
390
- echo "$draft_entry" | jq '{ok:true, action:"draft_saved", info:"Use '\''slack-cli drafts'\'' to list, '\''slack-cli send <id>'\'' to send.", draft:.}'
391
449
  }
392
450
 
393
451
  cmd_drafts() {
394
- if [[ ! -f "$DRAFTS_FILE" ]]; then
395
- echo '{"ok":true,"drafts":[]}' | jq .
396
- return 0
452
+ _check_creds
453
+
454
+ # List server-side drafts from Slack API
455
+ local response
456
+ response=$(_api_workspace drafts.list \
457
+ -F "is_active=true" \
458
+ -F "limit=25" \
459
+ -F "_x_reason=client-drafts-list" \
460
+ -F "_x_mode=online" \
461
+ -F "_x_sonic=true" \
462
+ -F "_x_app_name=client")
463
+
464
+ local ok
465
+ ok=$(echo "$response" | jq -r '.ok')
466
+ if [[ "$ok" == "true" ]]; then
467
+ echo "$response" | jq '{ok, drafts: [.drafts[]? | select(.is_deleted == false and .is_sent == false) | {
468
+ id: .id,
469
+ channel_id: .destinations[0].channel_id,
470
+ text: ([.blocks[]?.elements[]?.elements[]? | select(.type == "text") | .text] | join("")),
471
+ created: .date_created,
472
+ last_updated_ts: .last_updated_ts
473
+ }]}'
474
+ else
475
+ echo "$response" | jq '{ok, error}'
476
+ return 1
397
477
  fi
398
- jq '{ok:true, drafts:[.[] | select(.status == "draft")]}' "$DRAFTS_FILE"
399
478
  }
400
479
 
401
480
  cmd_send() {
@@ -406,21 +485,26 @@ cmd_send() {
406
485
  return 1
407
486
  fi
408
487
 
409
- if [[ ! -f "$DRAFTS_FILE" ]]; then
410
- echo '{"ok":false,"error":"No drafts found"}' | jq .
411
- return 1
412
- fi
488
+ # Fetch drafts to find the one to send
489
+ local drafts_response
490
+ drafts_response=$(_api_workspace drafts.list \
491
+ -F "is_active=true" \
492
+ -F "limit=25" \
493
+ -F "_x_reason=client-drafts-list" \
494
+ -F "_x_mode=online" \
495
+ -F "_x_sonic=true" \
496
+ -F "_x_app_name=client")
413
497
 
414
498
  local draft
415
- draft=$(jq -r --arg id "$draft_id" '.[] | select(.id == $id and .status == "draft")' "$DRAFTS_FILE")
499
+ draft=$(echo "$drafts_response" | jq -r --arg id "$draft_id" '.drafts[]? | select(.id == $id and .is_deleted == false and .is_sent == false)')
416
500
  if [[ -z "$draft" ]]; then
417
- echo '{"ok":false,"error":"Draft not found or already sent"}' | jq .
501
+ echo '{"ok":false,"error":"Draft not found or already sent. Use slack-cli drafts to list available drafts."}' | jq .
418
502
  return 1
419
503
  fi
420
504
 
421
505
  local channel_id message
422
- channel_id=$(echo "$draft" | jq -r '.channel_id')
423
- message=$(echo "$draft" | jq -r '.message')
506
+ channel_id=$(echo "$draft" | jq -r '.destinations[0].channel_id')
507
+ message=$(echo "$draft" | jq -r '[.blocks[]?.elements[]?.elements[]? | select(.type == "text") | .text] | join("")')
424
508
 
425
509
  echo "About to send to channel $channel_id:" >&2
426
510
  echo "$message" >&2
@@ -440,8 +524,17 @@ cmd_send() {
440
524
  local ok
441
525
  ok=$(echo "$response" | jq -r '.ok')
442
526
  if [[ "$ok" == "true" ]]; then
443
- jq --arg id "$draft_id" '[.[] | if .id == $id then .status = "sent" else . end]' "$DRAFTS_FILE" > "${DRAFTS_FILE}.tmp"
444
- mv "${DRAFTS_FILE}.tmp" "$DRAFTS_FILE"
527
+ # Delete the server-side draft after successful send
528
+ local now_ts
529
+ now_ts=$(python3 -c "import time; print(f'{time.time():.6f}')")
530
+ _api_workspace drafts.delete \
531
+ -F "draft_id=$draft_id" \
532
+ -F "client_last_updated_ts=$now_ts" \
533
+ -F "_x_reason=MessageInput:deleteDraft" \
534
+ -F "_x_mode=online" \
535
+ -F "_x_sonic=true" \
536
+ -F "_x_app_name=client" > /dev/null 2>&1
537
+
445
538
  echo "$response" | jq '{ok, action:"sent", channel:.channel, ts:.ts}'
446
539
  else
447
540
  echo "$response" | jq '{ok, error}'
@@ -523,7 +616,7 @@ COMMANDS:
523
616
  search <query> Search messages (full Slack query syntax)
524
617
  read <channel> [count] Read messages from a channel (name or ID)
525
618
  unreads Show recent activity
526
- draft <channel> <message> Save a draft locally (does NOT send)
619
+ draft <channel> <message> Save a draft to Slack (syncs across devices)
527
620
  drafts List saved drafts
528
621
  send <draft_id> Send a draft (interactive confirmation)
529
622
  channels List your channels
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alankyshum/slack-cli",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Slack CLI for coding agents — search, read, and draft messages via the Slack Web API using browser-extracted tokens",
5
5
  "bin": {
6
6
  "slack-cli": "./bin/slack-cli"