@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.
- package/LICENSE +21 -0
- package/README.md +117 -0
- package/bin/slack-cli +586 -0
- 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
|
+
}
|