@alankyshum/slack-cli 1.0.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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +117 -0
  3. package/bin/slack-cli +586 -0
  4. package/package.json +42 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alan Shum
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,117 @@
1
+ # slack-cli
2
+
3
+ A Slack CLI designed for coding agents (Claude Code, Cursor, etc.) that provides structured JSON access to Slack workspaces via the Web API.
4
+
5
+ It works by extracting authentication tokens from your existing browser session — no Slack app or bot token required.
6
+
7
+ ## Install
8
+
9
+ **npm:**
10
+ ```bash
11
+ npm install -g @alankyshum/slack-cli
12
+ ```
13
+
14
+ **pip:**
15
+ ```bash
16
+ pip install slack-cli-agent
17
+ ```
18
+
19
+ **Manual:**
20
+ ```bash
21
+ git clone https://github.com/alankyshum/slack-cli.git
22
+ chmod +x slack-cli/bin/slack-cli
23
+ ln -s "$(pwd)/slack-cli/bin/slack-cli" ~/.local/bin/slack-cli
24
+ ```
25
+
26
+ ## Prerequisites
27
+
28
+ - `curl` and `jq` (for all commands)
29
+ - `python3` (for URL encoding)
30
+ - Node.js + [Playwright](https://playwright.dev/) (only for `auth` command)
31
+
32
+ ```bash
33
+ npm install -g playwright
34
+ npx playwright install chromium
35
+ ```
36
+
37
+ ## Quick Start
38
+
39
+ ```bash
40
+ # 1. Authenticate (opens browser to extract tokens)
41
+ slack-cli auth --domain mycompany.slack.com
42
+
43
+ # 2. Search messages
44
+ slack-cli search "from:@alice in:#engineering deployment"
45
+
46
+ # 3. Read channel history
47
+ slack-cli read engineering 10
48
+
49
+ # 4. Draft a message (saved locally, not sent)
50
+ slack-cli draft general "Hey team, the fix for JIRA-123 is ready"
51
+ ```
52
+
53
+ ## Commands
54
+
55
+ | Command | Description |
56
+ |---------|-------------|
57
+ | `auth --domain <dom>` | Extract tokens from browser session |
58
+ | `whoami` | Show authenticated user |
59
+ | `config` | Show current configuration |
60
+ | `search <query>` | Search messages (full Slack query syntax) |
61
+ | `read <channel> [count]` | Read channel messages |
62
+ | `unreads` | Show recent activity |
63
+ | `draft <channel> <msg>` | Save draft locally (does **not** send) |
64
+ | `drafts` | List saved drafts |
65
+ | `send <draft_id>` | Send a draft (with confirmation prompt) |
66
+ | `channels` | List your channels |
67
+ | `dms` | List recent DMs |
68
+ | `userinfo <user>` | Look up user by ID, email, or name |
69
+
70
+ ## Search Modifiers
71
+
72
+ Slack's full search syntax is supported:
73
+
74
+ | Modifier | Example | Description |
75
+ |----------|---------|-------------|
76
+ | `from:` | `from:@alice` / `from:me` | Messages from a person |
77
+ | `in:` | `in:#channel` / `in:@user` | Messages in channel or DM |
78
+ | `before:` | `before:2025-06-01` | Before a date |
79
+ | `after:` | `after:2025-01-01` | After a date |
80
+ | `on:` | `on:2025-03-15` | On exact date |
81
+ | `during:` | `during:january` | During a month or year |
82
+ | `has:` | `has::eyes:` / `has:pin` | Has reaction or pin |
83
+ | `is:` | `is:saved` / `is:thread` | Saved or thread messages |
84
+ | `"..."` | `"exact phrase"` | Exact phrase match |
85
+ | `-` | `-word` | Exclude word |
86
+ | `*` | `deploy*` | Wildcard (min 3 chars) |
87
+
88
+ ## Environment Variables
89
+
90
+ | Variable | Default | Description |
91
+ |----------|---------|-------------|
92
+ | `SLACK_CLI_HOME` | `~/.slack-cli` | Config directory |
93
+ | `SLACK_CLI_CHROME_PROFILE` | `~/.slack-cli/chrome-profile` | Chrome profile for auth |
94
+ | `SLACK_CLI_COUNT` | `20` | Results per page |
95
+ | `SLACK_CLI_SORT` | `timestamp` | Sort: `timestamp` or `score` |
96
+ | `SLACK_CLI_SORT_DIR` | `desc` | Direction: `asc` or `desc` |
97
+
98
+ ## How It Works
99
+
100
+ 1. **`auth`** opens a Chromium browser using your existing Chrome profile (via Playwright), navigates to your Slack workspace, and intercepts the `xoxc-` API token and `xoxd-` session cookie from live API requests.
101
+
102
+ 2. All other commands use direct **Slack Web API** calls via `curl` — no browser needed, making them fast and suitable for automation.
103
+
104
+ 3. **Drafts** are saved locally to `~/.slack-cli/drafts.json`. The `send` command requires interactive confirmation before posting.
105
+
106
+ 4. All output is **structured JSON**, making it easy to parse with `jq` or consume from AI agents.
107
+
108
+ ## Security
109
+
110
+ - Credentials are stored at `~/.slack-cli/credentials.json` with `600` permissions
111
+ - Tokens are `xoxc-` client tokens scoped to your user session
112
+ - The `send` command requires explicit `y` confirmation
113
+ - No data is sent to any third party
114
+
115
+ ## License
116
+
117
+ MIT
package/bin/slack-cli ADDED
@@ -0,0 +1,586 @@
1
+ #!/usr/bin/env bash
2
+ # slack-cli - Slack CLI for coding agents
3
+ # Uses Slack Web API with xoxc token extracted from authenticated browser session.
4
+ # Credentials stored at ~/.slack-cli/credentials.json
5
+ #
6
+ # Usage:
7
+ # slack-cli auth [--domain <workspace>.slack.com] - Extract tokens from browser
8
+ # slack-cli search <query> - Search messages (Slack query syntax)
9
+ # slack-cli read <channel> [n] - Read messages from a channel
10
+ # slack-cli draft <channel> <msg> - Save a draft locally (does NOT send)
11
+ # slack-cli send <draft_id> - Send a draft (with confirmation)
12
+ # slack-cli help - Show full help
13
+
14
+ set -euo pipefail
15
+
16
+ CONFIG_DIR="${SLACK_CLI_HOME:-$HOME/.slack-cli}"
17
+ CREDS_FILE="$CONFIG_DIR/credentials.json"
18
+ DRAFTS_FILE="$CONFIG_DIR/drafts.json"
19
+ SLACK_API="https://slack.com/api"
20
+
21
+ # ── Helpers ──────────────────────────────────────────────────────────────────
22
+
23
+ _check_deps() {
24
+ for cmd in curl jq; do
25
+ command -v "$cmd" &>/dev/null || { echo "ERROR: '$cmd' is required. Install it first." >&2; exit 1; }
26
+ done
27
+ }
28
+
29
+ _check_creds() {
30
+ if [[ ! -f "$CREDS_FILE" ]]; then
31
+ echo '{"ok":false,"error":"No credentials found. Run: slack-cli auth --domain <workspace>.slack.com"}' | jq .
32
+ exit 1
33
+ fi
34
+ }
35
+
36
+ _token() { jq -r .token "$CREDS_FILE"; }
37
+ _cookie() { jq -r .d_cookie "$CREDS_FILE"; }
38
+ _domain() { jq -r '.domain // empty' "$CREDS_FILE"; }
39
+
40
+ _api() {
41
+ local method="$1"; shift
42
+ local response
43
+ response=$(curl -s "$SLACK_API/$method" \
44
+ -H 'Content-Type: application/x-www-form-urlencoded' \
45
+ -H "Cookie: d=$(_cookie)" \
46
+ -d "token=$(_token)&$*")
47
+
48
+ local ok err
49
+ ok=$(echo "$response" | jq -r '.ok')
50
+ if [[ "$ok" != "true" ]]; then
51
+ err=$(echo "$response" | jq -r '.error // empty')
52
+ if [[ "$err" == "token_revoked" || "$err" == "invalid_auth" || "$err" == "not_authed" ]]; then
53
+ echo '{"ok":false,"error":"Token expired. Run: slack-cli auth --domain '"$(_domain)"'"}' | jq .
54
+ exit 1
55
+ fi
56
+ fi
57
+ echo "$response"
58
+ }
59
+
60
+ _urlencode() {
61
+ python3 -c "import sys,urllib.parse; print(urllib.parse.quote(sys.stdin.read().strip()))" 2>/dev/null
62
+ }
63
+
64
+ _resolve_channel() {
65
+ local name="${1#\#}"
66
+ local response channel_id
67
+ response=$(_api search.messages "query=in:%23${name}&count=1")
68
+ channel_id=$(echo "$response" | jq -r '.messages.matches[0]?.channel.id // empty')
69
+ echo "$channel_id"
70
+ }
71
+
72
+ _resolve_users() {
73
+ local ids="$1"
74
+ local map="{}"
75
+ IFS=',' read -ra id_arr <<< "$ids"
76
+ for uid in "${id_arr[@]}"; do
77
+ [[ -z "$uid" || "$uid" == "null" ]] && continue
78
+ local resp name
79
+ resp=$(_api users.info "user=$uid" 2>/dev/null)
80
+ name=$(echo "$resp" | jq -r '.user.profile.display_name // .user.name // empty' 2>/dev/null)
81
+ if [[ -n "$name" ]]; then
82
+ map=$(echo "$map" | jq --arg k "$uid" --arg v "$name" '. + {($k): $v}')
83
+ fi
84
+ done
85
+ echo "$map"
86
+ }
87
+
88
+ # ── Commands ─────────────────────────────────────────────────────────────────
89
+
90
+ cmd_auth() {
91
+ local domain=""
92
+ while [[ $# -gt 0 ]]; do
93
+ case "$1" in
94
+ --domain|-d) domain="$2"; shift 2 ;;
95
+ *) domain="$1"; shift ;;
96
+ esac
97
+ done
98
+
99
+ if [[ -z "$domain" ]]; then
100
+ # Check if we have a stored domain
101
+ if [[ -f "$CREDS_FILE" ]]; then
102
+ domain=$(_domain)
103
+ fi
104
+ if [[ -z "$domain" ]]; then
105
+ echo "Usage: slack-cli auth --domain <workspace>.slack.com" >&2
106
+ echo "Example: slack-cli auth --domain mycompany.slack.com" >&2
107
+ exit 1
108
+ fi
109
+ fi
110
+
111
+ # Normalize domain: strip protocol, ensure .slack.com suffix
112
+ domain="${domain#https://}"
113
+ domain="${domain#http://}"
114
+ domain="${domain%%/*}"
115
+ if [[ "$domain" != *".slack.com" ]]; then
116
+ domain="${domain}.slack.com"
117
+ fi
118
+
119
+ echo "Extracting Slack tokens from browser for $domain ..." >&2
120
+ mkdir -p "$CONFIG_DIR"
121
+
122
+ local profile_dir="${SLACK_CLI_CHROME_PROFILE:-$HOME/.slack-cli/chrome-profile}"
123
+
124
+ local tmp_script
125
+ tmp_script=$(mktemp /tmp/slack-cli-auth-XXXXXX.mjs)
126
+ cat > "$tmp_script" << NODESCRIPT
127
+ import { chromium } from 'playwright';
128
+ import { writeFileSync, mkdirSync } from 'fs';
129
+
130
+ const DOMAIN = '${domain}';
131
+ const PROFILE_DIR = '${profile_dir}';
132
+ const CREDS_FILE = '${CREDS_FILE}';
133
+ const CONFIG_DIR = '${CONFIG_DIR}';
134
+
135
+ async function extractTokens() {
136
+ mkdirSync(CONFIG_DIR, { recursive: true });
137
+ console.error('Launching browser with profile at ' + PROFILE_DIR + ' ...');
138
+ const context = await chromium.launchPersistentContext(PROFILE_DIR, {
139
+ headless: false,
140
+ channel: 'chrome',
141
+ args: ['--disable-blink-features=AutomationControlled'],
142
+ viewport: { width: 1280, height: 800 }
143
+ });
144
+
145
+ const page = context.pages()[0] || await context.newPage();
146
+ console.error('Navigating to https://' + DOMAIN + ' ...');
147
+ await page.goto('https://' + DOMAIN, { waitUntil: 'networkidle', timeout: 60000 });
148
+
149
+ const url = page.url();
150
+ if (url.includes('signin') || url.includes('/sso/')) {
151
+ console.error('ERROR: Not authenticated. Please log in to Slack in the browser window, then run auth again.');
152
+ await page.waitForTimeout(3000);
153
+ await context.close();
154
+ process.exit(1);
155
+ }
156
+
157
+ // Extract d cookie
158
+ const cookies = await context.cookies();
159
+ const dCookie = cookies.find(c => c.name === 'd' && c.domain.includes('slack.com'));
160
+ if (!dCookie) {
161
+ console.error('ERROR: Could not find session cookie.');
162
+ await context.close();
163
+ process.exit(1);
164
+ }
165
+
166
+ // Intercept API requests to capture xoxc token
167
+ let token = null;
168
+ const handler = (req) => {
169
+ const postData = req.postData();
170
+ if (postData) {
171
+ const match = postData.match(/token=(xoxc-[^&\\s]+)/);
172
+ if (match) token = decodeURIComponent(match[1]);
173
+ }
174
+ };
175
+ page.on('request', handler);
176
+
177
+ // Trigger API activity
178
+ await page.keyboard.press('Meta+g').catch(() => page.keyboard.press('Control+g').catch(() => {}));
179
+ await page.waitForTimeout(500);
180
+ await page.keyboard.press('Escape');
181
+ await page.waitForTimeout(1500);
182
+
183
+ if (!token) {
184
+ try {
185
+ const tab = await page.\$('button:has-text("DMs"), button:has-text("Activity")');
186
+ if (tab) { await tab.click(); await page.waitForTimeout(1500); }
187
+ } catch(e) {}
188
+ }
189
+
190
+ page.off('request', handler);
191
+
192
+ if (!token) {
193
+ console.error('ERROR: Could not capture API token. Try refreshing Slack in the browser and re-running auth.');
194
+ await context.close();
195
+ process.exit(1);
196
+ }
197
+
198
+ // Verify with auth.test
199
+ const authResp = await page.evaluate(async (t) => {
200
+ const resp = await fetch('/api/auth.test', {
201
+ method: 'POST',
202
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
203
+ body: 'token=' + t
204
+ });
205
+ return resp.json();
206
+ }, token);
207
+
208
+ const creds = {
209
+ token,
210
+ d_cookie: dCookie.value,
211
+ domain: DOMAIN,
212
+ team_id: authResp.team_id || '',
213
+ team: authResp.team || '',
214
+ user_id: authResp.user_id || '',
215
+ user: authResp.user || '',
216
+ url: authResp.url || '',
217
+ extracted_at: new Date().toISOString()
218
+ };
219
+
220
+ writeFileSync(CREDS_FILE, JSON.stringify(creds, null, 2) + '\\n', { mode: 0o600 });
221
+ console.error('Credentials saved for ' + creds.user + ' @ ' + creds.team + ' (' + creds.team_id + ')');
222
+ console.log(JSON.stringify({ ok: true, user: creds.user, team: creds.team, domain: DOMAIN }));
223
+ await context.close();
224
+ }
225
+
226
+ extractTokens().catch(err => { console.error('ERROR:', err.message); process.exit(1); });
227
+ NODESCRIPT
228
+
229
+ node "$tmp_script"
230
+ local exit_code=$?
231
+ rm -f "$tmp_script"
232
+ return $exit_code
233
+ }
234
+
235
+ cmd_search() {
236
+ _check_creds
237
+ local query="$*"
238
+ if [[ -z "$query" ]]; then
239
+ echo '{"ok":false,"error":"Usage: slack-cli search <query>"}' | jq .
240
+ echo >&2
241
+ echo "Search modifiers:" >&2
242
+ echo " from:@name / from:me Messages from a person" >&2
243
+ echo " in:#channel / in:@user Messages in a channel or DM" >&2
244
+ echo " before:YYYY-MM-DD Before a date" >&2
245
+ echo " after:YYYY-MM-DD After a date" >&2
246
+ echo " on:YYYY-MM-DD On exact date" >&2
247
+ echo " during:month During a month or year" >&2
248
+ echo " has::emoji: / has:pin Has reaction or pin" >&2
249
+ echo " is:saved / is:thread Saved or thread messages" >&2
250
+ echo ' "exact phrase" Exact match' >&2
251
+ echo " -word Exclude word" >&2
252
+ echo " wild* Wildcard (min 3 chars)" >&2
253
+ return 1
254
+ fi
255
+
256
+ local count="${SLACK_CLI_COUNT:-20}"
257
+ local sort="${SLACK_CLI_SORT:-timestamp}"
258
+ local sort_dir="${SLACK_CLI_SORT_DIR:-desc}"
259
+ local encoded_query
260
+ encoded_query=$(echo -n "$query" | _urlencode)
261
+
262
+ local response
263
+ response=$(_api search.messages "query=$encoded_query&count=$count&sort=$sort&sort_dir=$sort_dir")
264
+
265
+ echo "$response" | jq '{
266
+ ok: .ok,
267
+ query: .query,
268
+ total: .messages.total,
269
+ messages: [.messages.matches[]? | {
270
+ text,
271
+ user: .username,
272
+ channel: .channel.name,
273
+ channel_id: .channel.id,
274
+ ts,
275
+ permalink,
276
+ date: (.ts | split(".")[0] | tonumber | strftime("%Y-%m-%d %H:%M:%S"))
277
+ }]
278
+ }'
279
+ }
280
+
281
+ cmd_read() {
282
+ _check_creds
283
+ local channel="${1:-}"
284
+ local count="${2:-20}"
285
+
286
+ if [[ -z "$channel" ]]; then
287
+ echo '{"ok":false,"error":"Usage: slack-cli read <channel_name_or_id> [count]"}' | jq .
288
+ return 1
289
+ fi
290
+
291
+ local channel_id="$channel"
292
+ if [[ ! "$channel" =~ ^[CDG][A-Z0-9]+$ ]]; then
293
+ channel_id=$(_resolve_channel "$channel")
294
+ if [[ -z "$channel_id" || "$channel_id" == "null" ]]; then
295
+ echo "{\"ok\":false,\"error\":\"Could not resolve channel: $channel. Try the channel ID.\"}" | jq .
296
+ return 1
297
+ fi
298
+ fi
299
+
300
+ local response
301
+ response=$(_api conversations.history "channel=$channel_id&limit=$count")
302
+
303
+ local ok
304
+ ok=$(echo "$response" | jq -r '.ok')
305
+ if [[ "$ok" != "true" ]]; then
306
+ echo "$response" | jq '{ok, error}'
307
+ return 1
308
+ fi
309
+
310
+ local user_ids
311
+ user_ids=$(echo "$response" | jq -r '[.messages[]?.user] | unique | join(",")')
312
+ local user_map="{}"
313
+ if [[ -n "$user_ids" ]]; then
314
+ user_map=$(_resolve_users "$user_ids")
315
+ fi
316
+
317
+ echo "$response" | jq --argjson users "$user_map" --arg cid "$channel_id" '{
318
+ ok: .ok,
319
+ channel_id: $cid,
320
+ messages: [.messages[]? | {
321
+ text,
322
+ user_id: .user,
323
+ user: ($users[.user] // .user),
324
+ ts,
325
+ date: (.ts | split(".")[0] | tonumber | strftime("%Y-%m-%d %H:%M:%S")),
326
+ thread_ts: .thread_ts,
327
+ reply_count: .reply_count,
328
+ reactions: [.reactions[]? | {name, count}]
329
+ }] | reverse
330
+ }'
331
+ }
332
+
333
+ cmd_unreads() {
334
+ _check_creds
335
+ local response
336
+ response=$(_api search.messages "query=*&count=30&sort=timestamp&sort_dir=desc")
337
+ echo "$response" | jq '{
338
+ ok: .ok,
339
+ total_recent: .messages.total,
340
+ recent_messages: [.messages.matches[:30]? | .[]? | {
341
+ text: .text[0:200],
342
+ user: .username,
343
+ channel: .channel.name,
344
+ date: (.ts | split(".")[0] | tonumber | strftime("%Y-%m-%d %H:%M:%S")),
345
+ permalink
346
+ }]
347
+ }'
348
+ }
349
+
350
+ cmd_draft() {
351
+ _check_creds
352
+ local channel="${1:-}"
353
+ shift 2>/dev/null || true
354
+ local message="$*"
355
+
356
+ if [[ -z "$channel" || -z "$message" ]]; then
357
+ echo '{"ok":false,"error":"Usage: slack-cli draft <channel_name_or_id> <message>"}' | jq .
358
+ return 1
359
+ fi
360
+
361
+ local channel_id="$channel"
362
+ if [[ ! "$channel" =~ ^[CDG][A-Z0-9]+$ ]]; then
363
+ channel_id=$(_resolve_channel "$channel")
364
+ if [[ -z "$channel_id" || "$channel_id" == "null" ]]; then
365
+ echo "{\"ok\":false,\"error\":\"Could not resolve channel: $channel\"}" | jq .
366
+ return 1
367
+ fi
368
+ fi
369
+
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"
386
+ else
387
+ echo "[$draft_entry]" | jq . > "$DRAFTS_FILE"
388
+ 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
+ }
392
+
393
+ cmd_drafts() {
394
+ if [[ ! -f "$DRAFTS_FILE" ]]; then
395
+ echo '{"ok":true,"drafts":[]}' | jq .
396
+ return 0
397
+ fi
398
+ jq '{ok:true, drafts:[.[] | select(.status == "draft")]}' "$DRAFTS_FILE"
399
+ }
400
+
401
+ cmd_send() {
402
+ _check_creds
403
+ local draft_id="${1:-}"
404
+ if [[ -z "$draft_id" ]]; then
405
+ echo '{"ok":false,"error":"Usage: slack-cli send <draft_id>"}' | jq .
406
+ return 1
407
+ fi
408
+
409
+ if [[ ! -f "$DRAFTS_FILE" ]]; then
410
+ echo '{"ok":false,"error":"No drafts found"}' | jq .
411
+ return 1
412
+ fi
413
+
414
+ local draft
415
+ draft=$(jq -r --arg id "$draft_id" '.[] | select(.id == $id and .status == "draft")' "$DRAFTS_FILE")
416
+ if [[ -z "$draft" ]]; then
417
+ echo '{"ok":false,"error":"Draft not found or already sent"}' | jq .
418
+ return 1
419
+ fi
420
+
421
+ local channel_id message
422
+ channel_id=$(echo "$draft" | jq -r '.channel_id')
423
+ message=$(echo "$draft" | jq -r '.message')
424
+
425
+ echo "About to send to channel $channel_id:" >&2
426
+ echo "$message" >&2
427
+ echo >&2
428
+ read -p "Confirm send? [y/N] " -r confirm
429
+ if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then
430
+ echo '{"ok":false,"action":"cancelled"}' | jq .
431
+ return 0
432
+ fi
433
+
434
+ local encoded_message
435
+ encoded_message=$(echo -n "$message" | _urlencode)
436
+
437
+ local response
438
+ response=$(_api chat.postMessage "channel=$channel_id&text=$encoded_message")
439
+
440
+ local ok
441
+ ok=$(echo "$response" | jq -r '.ok')
442
+ 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"
445
+ echo "$response" | jq '{ok, action:"sent", channel:.channel, ts:.ts}'
446
+ else
447
+ echo "$response" | jq '{ok, error}'
448
+ fi
449
+ }
450
+
451
+ cmd_channels() {
452
+ _check_creds
453
+ local response
454
+ response=$(_api search.messages "query=from:me&count=100&sort=timestamp&sort_dir=desc")
455
+ echo "$response" | jq '{
456
+ ok: .ok,
457
+ channels: [.messages.matches[]? | {name:.channel.name, id:.channel.id}] | unique_by(.id) | sort_by(.name)
458
+ }'
459
+ }
460
+
461
+ cmd_dms() {
462
+ _check_creds
463
+ local response
464
+ response=$(_api search.messages "query=from:me&count=50&sort=timestamp&sort_dir=desc")
465
+ echo "$response" | jq '{
466
+ ok: .ok,
467
+ recent_dms: [.messages.matches[]? | select(.channel.is_im == true or (.channel.name | test("^D|^mpdm"))) | {
468
+ channel:.channel.name, channel_id:.channel.id,
469
+ last_message:.text[0:100],
470
+ date: (.ts | split(".")[0] | tonumber | strftime("%Y-%m-%d %H:%M:%S"))
471
+ }] | unique_by(.channel_id)
472
+ }'
473
+ }
474
+
475
+ cmd_userinfo() {
476
+ _check_creds
477
+ local user="${1:-}"
478
+ if [[ -z "$user" ]]; then
479
+ echo '{"ok":false,"error":"Usage: slack-cli userinfo <user_id_or_email>"}' | jq .
480
+ return 1
481
+ fi
482
+
483
+ local response
484
+ if [[ "$user" == *"@"* ]]; then
485
+ response=$(_api users.lookupByEmail "email=$user")
486
+ elif [[ "$user" =~ ^U[A-Z0-9]+$ ]]; then
487
+ response=$(_api users.info "user=$user")
488
+ else
489
+ response=$(_api search.messages "query=from:@$user&count=1")
490
+ echo "$response" | jq '{ok, note:"Partial match via search. Use user ID or email for exact lookup.", match:(.messages.matches[0]? | {username, text:.text[0:100], channel:.channel.name})}'
491
+ return 0
492
+ fi
493
+
494
+ echo "$response" | jq '{ok, user:{id:.user.id, name:.user.name, real_name:.user.real_name, display_name:.user.profile.display_name, email:.user.profile.email, title:.user.profile.title, status:.user.profile.status_text, tz:.user.tz}}'
495
+ }
496
+
497
+ cmd_whoami() {
498
+ _check_creds
499
+ _api auth.test | jq '{ok, user, user_id, team, team_id, url}'
500
+ }
501
+
502
+ cmd_config() {
503
+ if [[ -f "$CREDS_FILE" ]]; then
504
+ jq '{domain, team, user, user_id, team_id, extracted_at}' "$CREDS_FILE"
505
+ else
506
+ echo '{"error":"Not configured. Run: slack-cli auth --domain <workspace>.slack.com"}' | jq .
507
+ fi
508
+ }
509
+
510
+ cmd_help() {
511
+ cat << 'EOF'
512
+ slack-cli - Slack CLI for coding agents
513
+
514
+ SETUP:
515
+ slack-cli auth --domain <workspace>.slack.com
516
+ Opens a browser to extract API tokens from your authenticated Slack session.
517
+ Requires: playwright (npm i -g playwright), Chrome browser with active Slack login.
518
+
519
+ COMMANDS:
520
+ auth [--domain <dom>] Extract tokens from browser session
521
+ whoami Show current authenticated user
522
+ config Show current configuration
523
+ search <query> Search messages (full Slack query syntax)
524
+ read <channel> [count] Read messages from a channel (name or ID)
525
+ unreads Show recent activity
526
+ draft <channel> <message> Save a draft locally (does NOT send)
527
+ drafts List saved drafts
528
+ send <draft_id> Send a draft (interactive confirmation)
529
+ channels List your channels
530
+ dms List recent DMs
531
+ userinfo <user> Look up user by ID, email, or name
532
+ help Show this help
533
+
534
+ SEARCH MODIFIERS:
535
+ from:@name / from:me Messages from a person
536
+ in:#channel / in:@user Messages in a channel or DM
537
+ before:YYYY-MM-DD Before a date
538
+ after:YYYY-MM-DD After a date
539
+ on:YYYY-MM-DD On exact date
540
+ during:month During a month or year
541
+ has::emoji: / has:pin Has reaction or pin
542
+ is:saved / is:thread Saved or thread messages
543
+ "exact phrase" Exact match
544
+ -word Exclude word
545
+ wild* Wildcard (min 3 chars)
546
+
547
+ ENVIRONMENT VARIABLES:
548
+ SLACK_CLI_HOME Config directory (default: ~/.slack-cli)
549
+ SLACK_CLI_CHROME_PROFILE Chrome profile path for auth (default: ~/.slack-cli/chrome-profile)
550
+ SLACK_CLI_COUNT Results per page (default: 20)
551
+ SLACK_CLI_SORT Sort by: timestamp or score (default: timestamp)
552
+ SLACK_CLI_SORT_DIR Sort direction: asc or desc (default: desc)
553
+
554
+ EXAMPLES:
555
+ slack-cli auth --domain mycompany.slack.com
556
+ slack-cli search "from:me in:#engineering after:2025-01-01"
557
+ slack-cli search "deployment error has::eyes:"
558
+ slack-cli read engineering 10
559
+ slack-cli draft general "Hey team, the fix is ready for review"
560
+ slack-cli userinfo user@company.com
561
+
562
+ All output is structured JSON for easy parsing by scripts and AI agents.
563
+ EOF
564
+ }
565
+
566
+ # ── Main ─────────────────────────────────────────────────────────────────────
567
+
568
+ _check_deps
569
+
570
+ case "${1:-help}" in
571
+ auth) shift; cmd_auth "$@" ;;
572
+ search) shift; cmd_search "$@" ;;
573
+ read) shift; cmd_read "$@" ;;
574
+ history) shift; cmd_read "$@" ;;
575
+ unreads) cmd_unreads ;;
576
+ draft) shift; cmd_draft "$@" ;;
577
+ drafts) cmd_drafts ;;
578
+ send) shift; cmd_send "$@" ;;
579
+ channels) cmd_channels ;;
580
+ dms) cmd_dms ;;
581
+ userinfo) shift; cmd_userinfo "$@" ;;
582
+ whoami) cmd_whoami ;;
583
+ config) cmd_config ;;
584
+ help|--help|-h) cmd_help ;;
585
+ *) echo "Unknown command: $1. Run 'slack-cli help'." >&2; exit 1 ;;
586
+ esac
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@alankyshum/slack-cli",
3
+ "version": "1.0.0",
4
+ "description": "Slack CLI for coding agents — search, read, and draft messages via the Slack Web API using browser-extracted tokens",
5
+ "bin": {
6
+ "slack-cli": "./bin/slack-cli"
7
+ },
8
+ "scripts": {
9
+ "lint:shell": "shellcheck bin/slack-cli",
10
+ "lint:python": "ruff check slack_cli/",
11
+ "lint": "npm run lint:shell && npm run lint:python",
12
+ "format": "ruff format slack_cli/"
13
+ },
14
+ "keywords": [
15
+ "slack",
16
+ "cli",
17
+ "agent",
18
+ "search",
19
+ "messages",
20
+ "automation"
21
+ ],
22
+ "author": "Alan Shum",
23
+ "license": "MIT",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/alankyshum/slack-cli.git"
27
+ },
28
+ "homepage": "https://github.com/alankyshum/slack-cli#readme",
29
+ "bugs": {
30
+ "url": "https://github.com/alankyshum/slack-cli/issues"
31
+ },
32
+ "engines": {
33
+ "node": ">=16"
34
+ },
35
+ "os": [
36
+ "darwin",
37
+ "linux"
38
+ ],
39
+ "files": [
40
+ "bin/slack-cli"
41
+ ]
42
+ }