slack-term 1.7.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 +127 -0
- package/SKILL.md +132 -0
- package/dist/cli.js +37 -0
- package/package.json +62 -0
- package/ts/cli.ts +1010 -0
- package/ts/format.ts +77 -0
- package/ts/profiles.ts +190 -0
- package/ts/slack-app.ts +243 -0
- package/ts/slack.ts +493 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 sno
|
|
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,127 @@
|
|
|
1
|
+
# slack — Slack CLI
|
|
2
|
+
|
|
3
|
+
A lightweight Slack CLI for quick workspace interaction from the terminal.
|
|
4
|
+
Two implementations — **TypeScript** (bun-first, published to npm) and **Rust**
|
|
5
|
+
(native binary, `cargo install`) — share one command surface and are verified
|
|
6
|
+
byte-for-byte by [`tests/parity.sh`](tests/parity.sh).
|
|
7
|
+
|
|
8
|
+
## Features
|
|
9
|
+
|
|
10
|
+
- **News** — Activity feed showing recent mentions (`to:me`), grouped by day with human-readable timestamps
|
|
11
|
+
- **Messages** — Browse recent messages across joined channels
|
|
12
|
+
- **Search** — Full-text search across the workspace
|
|
13
|
+
- **Send** — Send messages to channels or DMs with a confirm-hash safety gate (prevents accidental sends)
|
|
14
|
+
- **Dump** — Bulk-export channel history as markdown
|
|
15
|
+
|
|
16
|
+
### Output formatting
|
|
17
|
+
|
|
18
|
+
- DM channels display as `@DisplayName`, public channels as `#channel-name`
|
|
19
|
+
- Slack `<@UID>` mention tokens are resolved to display names
|
|
20
|
+
- Slack `<!date^...>` markup is rendered as human-readable dates
|
|
21
|
+
- Messages are grouped by day (Today / Yesterday / weekday)
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
### TypeScript (npm, recommended)
|
|
26
|
+
|
|
27
|
+
One package, no native binaries, any platform with Node 18+.
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
npm install -g @snomiao/slack
|
|
31
|
+
# or: bun add -g @snomiao/slack | pnpm add -g @snomiao/slack
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Rust (cargo)
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
cargo install --path rs
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Both expose the same `slack` command.
|
|
41
|
+
|
|
42
|
+
## Usage
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
# Activity feed (mentions directed to you)
|
|
46
|
+
slack news
|
|
47
|
+
slack news --limit 5
|
|
48
|
+
|
|
49
|
+
# Recent messages across joined channels
|
|
50
|
+
slack msgs
|
|
51
|
+
|
|
52
|
+
# Search messages
|
|
53
|
+
slack search "deploy"
|
|
54
|
+
slack search "deploy" --count 50
|
|
55
|
+
|
|
56
|
+
# Send a message (two-step confirm — quote #channel)
|
|
57
|
+
slack send "#general" "Hello team"
|
|
58
|
+
# Prints preview + confirm hash; rerun with --confirm=<hash> to actually send
|
|
59
|
+
slack send "#general" "Hello team" --confirm=<hash>
|
|
60
|
+
|
|
61
|
+
# Bulk export channel history
|
|
62
|
+
slack dump --days 7 --filter eng
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Configuration
|
|
66
|
+
|
|
67
|
+
Requires a Slack user token (`xoxp-...`) with the following scopes:
|
|
68
|
+
|
|
69
|
+
- `search:read` — for search and news
|
|
70
|
+
- `channels:history`, `groups:history`, `im:history`, `mpim:history` — for message history
|
|
71
|
+
- `channels:read`, `groups:read`, `im:read`, `mpim:read` — for channel listing
|
|
72
|
+
- `users:read` — for resolving display names
|
|
73
|
+
- `chat:write` — for sending messages
|
|
74
|
+
|
|
75
|
+
Set the token via environment variable:
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
export SLACK_MCP_XOXP_TOKEN=xoxp-...
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Or place it in `~/.config/slack-cli/.env` or a local `.env` file.
|
|
82
|
+
|
|
83
|
+
See [`SKILL.md`](SKILL.md) for a full token-acquisition walkthrough.
|
|
84
|
+
|
|
85
|
+
## Development
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
# TypeScript
|
|
89
|
+
bun install
|
|
90
|
+
bun run dev -- news --limit 3 # run straight from source
|
|
91
|
+
bun run typecheck
|
|
92
|
+
bun run build # produces dist/cli.js
|
|
93
|
+
|
|
94
|
+
# Rust
|
|
95
|
+
cargo run --manifest-path rs/Cargo.toml --release --bin slack -- news --limit 3
|
|
96
|
+
|
|
97
|
+
# Parity test (requires a token — compares Rust and TS stdout)
|
|
98
|
+
bun run test:parity
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Dependencies
|
|
102
|
+
|
|
103
|
+
**TypeScript impl** — zero runtime deps; uses built-in `fetch`, `node:crypto`,
|
|
104
|
+
`node:util` argument parsing.
|
|
105
|
+
|
|
106
|
+
**Rust impl**
|
|
107
|
+
|
|
108
|
+
- [clap](https://crates.io/crates/clap) — CLI argument parsing
|
|
109
|
+
- [reqwest](https://crates.io/crates/reqwest) — HTTP client for Slack Web API
|
|
110
|
+
- [tokio](https://crates.io/crates/tokio) — async runtime
|
|
111
|
+
- [chrono](https://crates.io/crates/chrono) — date/time formatting
|
|
112
|
+
- [ring](https://crates.io/crates/ring) — SHA-256 for confirm hashes
|
|
113
|
+
|
|
114
|
+
## Related / prior art
|
|
115
|
+
|
|
116
|
+
- [`slkcli`](https://www.npmjs.com/package/slkcli) by
|
|
117
|
+
[@therohitdas](https://github.com/therohitdas) — a macOS-only Node CLI that
|
|
118
|
+
auto-extracts `xoxc-` session tokens from the Slack desktop app. Different
|
|
119
|
+
tradeoffs (zero-config on macOS vs. our cross-platform explicit-token
|
|
120
|
+
approach). See [`docs/comparison-slkcli.md`](docs/comparison-slkcli.md) for
|
|
121
|
+
a full UX side-by-side.
|
|
122
|
+
- [`docs/ecosystem.md`](docs/ecosystem.md) — survey of other terminal Slack
|
|
123
|
+
tools (official, `slack-term`, `wee-slack`, `slackcat`, `slackdump`, …).
|
|
124
|
+
|
|
125
|
+
## License
|
|
126
|
+
|
|
127
|
+
MIT
|
package/SKILL.md
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: slack-cli
|
|
3
|
+
description: "Terminal Slack CLI — read news/mentions/DMs/channel history, full-text search, and send messages with a confirm-hash gate. Also covers first-time setup of a user OAuth token (xoxp-...) via SLACK_MCP_XOXP_TOKEN. Trigger on requests like 'check my Slack', 'any mentions?', 'search Slack for X', 'DM @person', 'post to a channel', 'read a user', 'slack news', or auth errors (invalid_auth, missing_scope, not_authed) — even if the user doesn't say 'slack CLI' explicitly."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Slack CLI Skill
|
|
7
|
+
|
|
8
|
+
A lightweight Slack CLI for quick workspace interaction from the terminal.
|
|
9
|
+
Two implementations share one command surface (parity-tested):
|
|
10
|
+
|
|
11
|
+
- **TypeScript** (bun-first), published as `@snomiao/slack` on npm.
|
|
12
|
+
- **Rust** native binary, installable via `cargo install`.
|
|
13
|
+
|
|
14
|
+
Binary name: `slack` (also aliased as `sl` / invoked via `sc sl ...` in some setups).
|
|
15
|
+
|
|
16
|
+
## When to use
|
|
17
|
+
|
|
18
|
+
- User wants to read Slack activity, mentions, DMs, or channel history without opening the Slack app.
|
|
19
|
+
- User wants to search the workspace full-text.
|
|
20
|
+
- User wants to send a message to a channel or user from the terminal.
|
|
21
|
+
- User is setting up this CLI for the first time or hitting auth errors.
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
TypeScript (recommended — any platform with Node 18+):
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
npm install -g @snomiao/slack
|
|
29
|
+
# or: bun add -g @snomiao/slack / pnpm add -g @snomiao/slack
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Rust (native binary):
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
cargo install --path rs
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Commands
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
# Activity feed — recent mentions directed to you (to:me)
|
|
42
|
+
slack news
|
|
43
|
+
slack news --limit 5
|
|
44
|
+
|
|
45
|
+
# Recent messages across joined channels
|
|
46
|
+
slack msgs
|
|
47
|
+
|
|
48
|
+
# Full-text search
|
|
49
|
+
slack search "deploy"
|
|
50
|
+
slack search "deploy" --count 50
|
|
51
|
+
|
|
52
|
+
# Read a specific channel or DM (quote #channel — unquoted # is a shell comment)
|
|
53
|
+
slack read "#general"
|
|
54
|
+
slack read @username
|
|
55
|
+
|
|
56
|
+
# Send a message (two-step confirm)
|
|
57
|
+
slack send "#general" "Hello team"
|
|
58
|
+
# Prints preview + a confirm hash. Re-run with --confirm=<hash> to actually send:
|
|
59
|
+
slack send "#general" "Hello team" --confirm=<hash>
|
|
60
|
+
|
|
61
|
+
# Bulk export a channel's history
|
|
62
|
+
slack dump "#channel-name"
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Targets for `send` must be `#channel` or `@user` — raw IDs are rejected by design.
|
|
66
|
+
|
|
67
|
+
## Output formatting
|
|
68
|
+
|
|
69
|
+
- DM channels render as `@DisplayName`; public channels as `#channel-name`.
|
|
70
|
+
- `<@UID>` mention tokens are resolved to display names.
|
|
71
|
+
- `<!date^...>` markup is rendered as human-readable dates.
|
|
72
|
+
- Messages are grouped by day (Today / Yesterday / weekday).
|
|
73
|
+
|
|
74
|
+
## Getting a Slack token (first-time setup)
|
|
75
|
+
|
|
76
|
+
The CLI needs a **User OAuth Token** (`xoxp-...`), NOT a Bot Token.
|
|
77
|
+
|
|
78
|
+
### Required scopes
|
|
79
|
+
|
|
80
|
+
Under **User Token Scopes**:
|
|
81
|
+
|
|
82
|
+
- `search:read` — for `search` and `news`
|
|
83
|
+
- `channels:history`, `groups:history`, `im:history`, `mpim:history` — message history
|
|
84
|
+
- `channels:read`, `groups:read`, `im:read`, `mpim:read` — channel/DM listing
|
|
85
|
+
- `users:read` — resolve display names
|
|
86
|
+
- `chat:write` — send messages
|
|
87
|
+
|
|
88
|
+
### Steps
|
|
89
|
+
|
|
90
|
+
1. Go to https://api.slack.com/apps → **Create New App → From scratch**.
|
|
91
|
+
2. Name the app (e.g. `slack-cli-<you>`) and pick the workspace.
|
|
92
|
+
3. Sidebar → **OAuth & Permissions**.
|
|
93
|
+
4. Scroll to **User Token Scopes** (NOT Bot Token Scopes) and add every scope above.
|
|
94
|
+
5. Scroll up → **Install to Workspace** → **Allow**.
|
|
95
|
+
- If admin approval is required, the button becomes **Request to Install**. Ask a workspace admin.
|
|
96
|
+
6. Copy the **User OAuth Token** (starts with `xoxp-`).
|
|
97
|
+
|
|
98
|
+
### Configure the token
|
|
99
|
+
|
|
100
|
+
```sh
|
|
101
|
+
# Option A — per-shell
|
|
102
|
+
export SLACK_MCP_XOXP_TOKEN=xoxp-...
|
|
103
|
+
|
|
104
|
+
# Option B — persistent, user-wide
|
|
105
|
+
mkdir -p ~/.config/slack-cli
|
|
106
|
+
echo 'SLACK_MCP_XOXP_TOKEN=xoxp-...' >> ~/.config/slack-cli/.env
|
|
107
|
+
|
|
108
|
+
# Option C — project-local
|
|
109
|
+
echo 'SLACK_MCP_XOXP_TOKEN=xoxp-...' >> .env
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Verify:
|
|
113
|
+
|
|
114
|
+
```sh
|
|
115
|
+
slack news --limit 1
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Troubleshooting
|
|
119
|
+
|
|
120
|
+
- **`invalid_auth` / `not_authed`** — token missing, mistyped, or revoked. Re-copy from **OAuth & Permissions**.
|
|
121
|
+
- **`missing_scope`** — add the scope from the error, then click **Reinstall to Workspace** (scope changes require reinstall).
|
|
122
|
+
- **`token_revoked`** — app uninstalled; reinstall from the app page.
|
|
123
|
+
- **Token starts with `xoxb-`** — that's a Bot Token. Add scopes under **User Token Scopes** instead, reinstall, and copy the **User OAuth Token**.
|
|
124
|
+
- **Send is rejected with "use #channel or @user"** — the CLI enforces human-readable targets. Use `#channel-name` or `@display-name`, not raw IDs.
|
|
125
|
+
- **Confirm hash mismatch on `send`** — the message text changed between preview and confirm. Re-run without `--confirm` to get a fresh hash.
|
|
126
|
+
- **Enterprise Grid / admin-locked workspace** — custom app installation may need admin approval or be disabled outright.
|
|
127
|
+
|
|
128
|
+
## Safety
|
|
129
|
+
|
|
130
|
+
- Treat `xoxp-...` like a password. Do not commit `.env` containing a real token — ensure it's gitignored.
|
|
131
|
+
- Revoke unused tokens from the app's **OAuth & Permissions** page.
|
|
132
|
+
- The `send` command's two-step confirm-hash flow is intentional — don't try to bypass it by auto-computing the hash; let the user (or Claude) see the preview first.
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// @bun
|
|
3
|
+
import{createRequire as nJ}from"node:module";var w=nJ(import.meta.url);import{parseArgs as L}from"util";import{createHash as WQ}from"crypto";import{existsSync as cJ,readFileSync as _Q,mkdirSync as EQ,writeFileSync as XQ}from"fs";import{homedir as iJ}from"os";import{join as C}from"path";import{existsSync as o,readFileSync as OJ,writeFileSync as s,mkdirSync as VJ}from"node:fs";import{homedir as KJ}from"node:os";import{join as h,dirname as HJ}from"node:path";function FJ(){return h(KJ(),".config","slack-cli","profiles.json")}function p(){return h(process.cwd(),".slack-cli","workspace")}function m(){return h(KJ(),".slack-cli","workspace")}function R(){let Q=FJ();if(!o(Q))return{profiles:{}};return JSON.parse(OJ(Q,"utf8"))}function a(Q){let J=FJ();VJ(HJ(J),{recursive:!0}),s(J,JSON.stringify(Q,null,2)+`
|
|
4
|
+
`)}function S(Q){if(!o(Q))return;return OJ(Q,"utf8").trim()||void 0}function BJ(Q,J){let Z=HJ(Q);VJ(Z,{recursive:!0});let $=h(Z,".gitignore");if(!o($))s($,`*
|
|
5
|
+
`);s(Q,J+`
|
|
6
|
+
`)}function e(){let Q=R(),J=S(p()),Z=S(m()),$=J??Z;return Object.entries(Q.profiles).map(([z,_])=>({name:z,profile:_,current:z===$}))}function k(Q,J){let Z=R();Z.profiles[Q]=J,a(Z)}function qJ(Q,J){let Z=R();if(!(Q in Z.profiles))throw Error(`Profile not found: ${Q}`);Z.profiles[Q].cookie=J,a(Z)}function MJ(Q){let J=R();if(!(Q in J.profiles))throw Error(`Profile not found: ${Q}`);delete J.profiles[Q],a(J)}function jJ(Q,J=!1){let Z=R();if(!(Q in Z.profiles))throw Error(`Profile not found: ${Q}`);if(J)BJ(m(),Q);else BJ(p(),Q)}function UJ(Q){let Z=R().profiles,$=Object.keys(Z),z=process.env.SLACK_MCP_XOXP_TOKEN;if(z&&$.length>0)throw Error(`Both SLACK_MCP_XOXP_TOKEN and workspace profiles are configured — keep only one.
|
|
7
|
+
`+` • Unset SLACK_MCP_XOXP_TOKEN to use profiles
|
|
8
|
+
`+" • Or remove all profiles: slack workspace remove <name>");if(z)return z;let _=Q??process.env.SLACK_WORKSPACE;if(_){let W=Z[_];if(!W)throw Error(`Workspace "${_}" not found. Available: ${$.join(", ")||"(none)"}`);return W.token}let E=S(p());if(E){let W=Z[E];if(!W)throw Error(`Workspace "${E}" (from .slack-cli/workspace) not found in profiles.`);return W.token}let G=S(m());if(G){let W=Z[G];if(!W)throw Error(`Workspace "${G}" (from ~/.slack-cli/workspace) not found in profiles.`);return W.token}if($.length===0)throw Error("No profiles configured. Run: slack workspace add <name> <token> — or set SLACK_MCP_XOXP_TOKEN");throw Error(`Workspace not selected (${$.join(", ")} available).
|
|
9
|
+
Select locally: slack workspace use <name> (writes .slack-cli/workspace)
|
|
10
|
+
Select globally: slack workspace use -g <name> (writes ~/.slack-cli/workspace)`)}function DJ(Q){let Z=R().profiles;if(process.env.SLACK_MCP_XOXP_TOKEN)return process.env.SLACK_MCP_XOXD_COOKIE;let z=Q??process.env.SLACK_WORKSPACE;if(z)return Z[z]?.cookie;let _=S(p());if(_)return Z[_]?.cookie;let E=S(m());if(E)return Z[E]?.cookie;return}import{readdirSync as LJ,readFileSync as RJ,existsSync as TJ}from"node:fs";import{homedir as NJ}from"node:os";import{join as T}from"node:path";import{pbkdf2Sync as rJ,createDecipheriv as tJ}from"node:crypto";import{execSync as sJ}from"node:child_process";function oJ(){let Q=NJ();if(process.platform==="darwin")return T(Q,"Library","Application Support","Slack","Local Storage","leveldb");if(process.platform==="linux")return T(Q,".config","Slack","Local Storage","leveldb");if(process.platform==="win32"){let J=process.env.APPDATA??T(Q,"AppData","Roaming");return T(J,"Slack","Local Storage","leveldb")}throw Error(`Unsupported platform: ${process.platform}`)}function aJ(){let Q=NJ();if(process.platform==="darwin")return T(Q,"Library","Application Support","Slack","Cookies");return""}function eJ(Q,J){if(Q.length<35||Q.slice(0,3).toString()!=="v10")throw Error("Not a v10 encrypted cookie");let Z=Q.slice(19,35),$=Q.slice(35),z=tJ("aes-128-cbc",J,Z);return z.setAutoPadding(!0),Buffer.concat([z.update($),z.final()]).toString("utf8")}function JQ(){if(process.platform!=="darwin")return;let Q=aJ();if(!TJ(Q))return;let J;try{J=sJ('security find-generic-password -w -s "Slack Safe Storage" -a "Slack Key"',{encoding:"utf8",stdio:["ignore","pipe","ignore"]}).trimEnd()}catch{return}let Z=rJ(J,"saltysalt",1003,16,"sha1");try{let{default:$}=w("bun:sqlite"),z=new $(Q,{readonly:!0}),_=z.prepare("SELECT encrypted_value FROM cookies WHERE name='d' AND host_key LIKE '%slack%'").get();if(z.close(),!_)return;return eJ(Buffer.from(_.encrypted_value),Z)}catch{return}}async function AJ(){let Q=oJ();if(!TJ(Q))throw Error(`Slack desktop app LevelDB not found at:
|
|
11
|
+
${Q}
|
|
12
|
+
Is Slack installed and opened at least once?`);let J=LJ(Q).filter((G)=>G.endsWith(".ldb")||G.endsWith(".log"));if(J.length===0)throw Error(`No LevelDB data files found in ${Q}`);let Z=new Map;for(let G of J){let W;try{W=RJ(T(Q,G),"latin1")}catch{continue}if(G.endsWith(".log"))for(let X of W.matchAll(/"token":"(xoxc-[^"]+)"/g)){let B=X[1],Y=B.split("-")[1]??"";if(!Y||B.length<40)continue;let O=Math.max(0,X.index-2000),K=Math.min(W.length,X.index+2000),V=W.slice(O,K),H=V.match(/"url":"(https:\/\/[a-z0-9-]+\.slack\.com\/)"/),F=V.match(/"(?:team_name|name)":"([^"]{2,60})"/),M=Z.get(Y);if(!M||B.length>M.token.length){let j={token:B,teamId:Y},u=H?.[1]??M?.url,YJ=F?.[1]??M?.teamName;if(u)j.url=u;if(YJ)j.teamName=YJ;Z.set(Y,j)}}else for(let X of W.matchAll(/xoxc-(\d+)-(\d+)-(\d+)/g)){let B=X[1];if(Z.has(B))continue;let Y=X.index+X[0].length,O=Math.min(Y+10,W.length);while(Y<O&&(W.charCodeAt(Y)<48||W.charCodeAt(Y)>57))Y++;let K="";while(Y<W.length&&W.charCodeAt(Y)>=48&&W.charCodeAt(Y)<=57)K+=W[Y++];if(W[Y]!=="-")continue;Y++;let V="";while(Y<W.length){let F=W.charCodeAt(Y);if(F>=48&&F<=57||F>=97&&F<=102)V+=W[Y++];else break}if(V.length<20)continue;let H=`${X[0]}${K}-${V}`;Z.set(B,{token:H,teamId:B})}}let z=LJ(Q).filter((G)=>G.endsWith(".ldb")||G.endsWith(".log")).map((G)=>{try{return RJ(T(Q,G),"latin1").replace(/[^\x20-\x7e]/g," ")}catch{return""}}).join(" ");for(let G of Z.values()){if(G.teamName)continue;let W=z.indexOf(G.teamId);if(W!==-1){let X=Math.max(0,W-500),B=Math.min(z.length,W+500),O=z.slice(X,B).match(/"(?:team_name|name)":"([^"]{2,60})"/);if(O?.[1]){G.teamName=O[1];continue}}if(!G.teamName&&G.url){let X=G.url.match(/https:\/\/([a-z0-9-]+)\.slack\.com\//)?.[1];if(X){let B=z.indexOf(X);if(B!==-1){let Y=Math.max(0,B-500),O=Math.min(z.length,B+500),V=z.slice(Y,O).match(/"(?:team_name|name)":"([^"]{2,60})"/);if(V?.[1]){G.teamName=V[1];continue}}G.teamName=X.split("-").map((Y)=>Y.charAt(0).toUpperCase()+Y.slice(1)).join(" ")}}}let _=JQ(),E=[...Z.values()];if(_)for(let G of E)G.cookie=_;return E}function PJ(){return(process.env.SLACK_API_BASE??"https://slack.com/api").replace(/\/$/,"")}async function CJ(Q,J,Z,$){let z={};if($)z.Cookie=`d=${$}`;let E=await(await fetch(`${PJ()}/${J}`,{...Z,headers:{...Z.headers??{},Authorization:`Bearer ${Q}`,...z}})).json();if(E.ok!==!0){let G=E.error??"unknown";if(G==="invalid_auth"&&Q.startsWith("xoxc-")&&!$)throw Error(`Desktop app token (xoxc-) is not accepted by the public Slack API.
|
|
13
|
+
Replace it with an xoxp- user token:
|
|
14
|
+
slack workspace set-token <name> <xoxp-token>`);throw Error(`Slack error on ${J}: ${G}`)}return E}async function QQ(Q,J,Z,$){let z={};if($)z.Cookie=`d=${$}`;let E=await(await fetch(`${PJ()}/${J}`,{...Z,headers:{...Z.headers??{},Authorization:`Bearer ${Q}`,...z}})).json();if(E.ok!==!0){let G=E.error??"unknown";if((G==="invalid_auth"||G==="not_authed")&&!Q.startsWith("xoxc-"))throw Error(`The draft API requires a desktop app session token (xoxc-).
|
|
15
|
+
Import from Slack desktop: slack workspace import`);if((G==="invalid_auth"||G==="not_authed")&&!$)throw Error(`drafts.list also requires the xoxd session cookie.
|
|
16
|
+
Set it with: slack workspace set-cookie <workspace-name> <xoxd-value>
|
|
17
|
+
`+"Get xoxd from browser: DevTools → Application → Cookies → slack.com → d");throw Error(`Slack error on ${J}: ${G}`)}return E}function v(Q,J,Z={},$){let z=new URLSearchParams({token:Q,...Z});return QQ(Q,J,{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:z.toString()},$)}function D(Q,J,Z,$){let z=new URLSearchParams(Z).toString();return CJ(Q,`${J}?${z}`,{method:"GET"},$)}function d(Q,J,Z){return CJ(Q,J,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(Z)})}async function JJ(Q){let J=await D(Q,"auth.test",{});return{team:J.team??"",teamId:J.team_id??"",url:J.url??"",user:J.user??""}}async function x(Q,J,Z=20){return D(Q,"conversations.history",{channel:J,limit:String(Z)})}async function QJ(Q,J,Z,$=50){return D(Q,"conversations.replies",{channel:J,ts:Z,limit:String($)})}async function wJ(Q,J,Z,$){return D(Q,"search.messages",{query:J,sort:"timestamp",sort_dir:"desc",count:String(Math.min(Math.max(Z,1),100)),page:String(Math.max($,1))})}async function SJ(Q,J){return wJ(Q,J,100,1)}async function vJ(Q,J,Z){let z=1,_=[],E={ok:!0,messages:{}};while(!0){let W=await wJ(Q,J,100,z),X=c(W,["messages","matches"]),B=Array.isArray(X)?X:[];_.push(...B);let Y=Number(c(W,["messages","paging","pages"])??1);if(E=W,B.length===0||z>=Y||_.length>=Z)break;z+=1}let G=E;if(!G.messages)G.messages={};return G.messages.matches=_.slice(0,Z),G}async function bJ(Q,J,Z,$){let z={channel:J,text:Z,blocks:[{type:"markdown",text:Z}]};if($!==void 0)z.thread_ts=$;return(await d(Q,"chat.postMessage",z)).ts??""}async function xJ(Q,J,Z,$){return(await d(Q,"chat.update",{channel:J,ts:Z,text:$,blocks:[{type:"markdown",text:$}]})).ts??Z}async function l(Q){let J=[],Z="";while(!0){let $={limit:"200",types:"public_channel,private_channel,im,mpim"};if(Z)$.cursor=Z;let z=await D(Q,"conversations.list",$);if(J.push(...z.channels??[]),Z=z.response_metadata?.next_cursor??"",!Z)break}return{channels:J}}async function ZJ(Q,J){let $=(await d(Q,"conversations.open",{users:J})).channel?.id;if(!$)throw Error(`Failed to open DM with user ${J}`);return $}function i(Q){return Q.toLowerCase().replace(/[-_\s]/g,"")}function $J(Q){let J=Q.match(/(?:app\.slack\.com\/client\/T[A-Za-z0-9]+|[A-Za-z0-9-]+\.slack\.com\/archives)\/([A-Za-z0-9]+)(?:\/p(\d{10})(\d{6}))?/);if(!J)return;let Z=J[1],$=J[2]&&J[3]?`${J[2]}.${J[3]}`:void 0;return $?{channel:Z,ts:$}:{channel:Z}}function ZQ(Q){return $J(Q)?.channel}async function N(Q,J,Z){let $=ZQ(J);if($)return $;if(!J.startsWith("@")&&!J.startsWith("#")){if(/^[A-Za-z0-9]{9,}$/.test(J))return J;throw Error(`Target must start with # or @ (or be a Slack URL/ID), got: ${J}`)}let z=J.startsWith("@"),_=J.slice(1),E=i(_);if(z){let X="",B="";while(!0){let O={limit:"200"};if(B)O.cursor=B;let K=await D(Q,"users.list",O,Z);for(let V of K.members??[]){let H=i(V.name??""),F=i(V.real_name??""),M=i(V.profile?.display_name??"");if(H===E||F===E||M&&M===E){X=V.id??"";break}}if(X)break;if(B=K.response_metadata?.next_cursor??"",!B)break}if(!X)throw Error(`User not found: ${J}`);let Y="";while(!0){let O={types:"im",limit:"200"};if(Y)O.cursor=Y;let K=await D(Q,"conversations.list",O,Z);for(let V of K.channels??[])if(V.user===X)return String(V.id??"");if(Y=K.response_metadata?.next_cursor??"",!Y)break}throw Error(`No existing DM with ${J} (${X}). Open it once in Slack first.`)}let G=_.toLowerCase(),W="";while(!0){let X={limit:"200",types:"public_channel,private_channel",exclude_archived:"true"};if(W)X.cursor=W;let B=await D(Q,"conversations.list",X,Z);for(let Y of B.channels??[])if(String(Y.name??"").toLowerCase()===G)return String(Y.id??"");if(W=B.response_metadata?.next_cursor??"",!W)break}throw Error(`channel not found: ${J}`)}async function zJ(Q,J){try{let Z=await D(Q,"users.info",{user:J}),$=Z.user?.profile?.display_name,z=$&&$.length>0?$:Z.user?.real_name??Z.user?.name??J,_=Z.user?.name??J;return[z,_]}catch{return[J,J]}}async function GJ(Q,J){try{let Z=await D(Q,"users.info",{user:J}),$=Z.user?.profile?.display_name;if($&&$.length>0)return $;return Z.user?.real_name??Z.user?.name??J}catch{return J}}async function I(Q,J){return v(Q,"drafts.list",{},J)}async function IJ(Q,J,Z,$){let{randomUUID:z}=await import("node:crypto"),_=JSON.stringify([{type:"rich_text",elements:[{type:"rich_text_section",elements:[{type:"text",text:Z}]}]}]);return v(Q,"drafts.create",{client_msg_id:z(),destinations:JSON.stringify([{channel_id:J}]),blocks:_,file_ids:JSON.stringify([]),is_from_composer:"true"},$)}async function yJ(Q,J,Z,$,z){let _=JSON.stringify([{type:"rich_text",elements:[{type:"rich_text_section",elements:[{type:"text",text:$}]}]}]),E=(Date.now()/1000).toFixed(6);return v(Q,"drafts.update",{draft_id:J,client_last_updated_ts:E,destinations:JSON.stringify([{channel_id:Z}]),blocks:_,file_ids:JSON.stringify([])},z)}async function fJ(Q,J,Z){return v(Q,"conversations.info",{channel:J},Z)}async function gJ(Q,J,Z){let $=(Date.now()/1000).toFixed(6);return v(Q,"drafts.delete",{draft_id:J,client_last_updated_ts:$},Z)}async function WJ(Q,J){let Z=await v(Q,"auth.test",{},J);return{userId:String(Z.user_id??""),teamId:String(Z.team_id??"")}}async function uJ(Q,J,Z,$={}){let{statSync:z,readFileSync:_}=await import("node:fs"),{basename:E}=await import("node:path"),G=z(Z),W=E(Z),X=await D(Q,"files.getUploadURLExternal",{filename:W,length:String(G.size)}),B=X.upload_url,Y=X.file_id;if(!B||!Y)throw Error("files.getUploadURLExternal returned no upload_url/file_id");let O=_(Z),K=await fetch(B,{method:"POST",headers:{"Content-Type":"application/octet-stream"},body:O});if(!K.ok)throw Error(`Upload PUT failed: ${K.status} ${K.statusText}`);let V={files:[{id:Y,title:$.title??W}],channel_id:J};if($.threadTs)V.thread_ts=$.threadTs;if($.initialComment)V.initial_comment=$.initialComment;let H=await d(Q,"files.completeUploadExternal",V),F={fileId:Y},M=H.files?.[0]?.permalink;if(M)F.permalink=M;return F}function c(Q,J){let Z=Q;for(let $ of J){if(Z===void 0||Z===null)return;if(typeof $==="number"){if(!Array.isArray(Z))return;Z=Z[$]??void 0}else{if(typeof Z!=="object"||Array.isArray(Z))return;Z=Z[$]}}return Z}function $Q(){return(process.env.SLACK_API_BASE??"https://slack.com/api").replace(/\/$/,"")}async function zQ(Q,J,Z,$){let z={};if($)z.Cookie=`d=${$}`;let E=await(await fetch(`${$Q()}/${J}`,{...Z,headers:{...Z.headers??{},Authorization:`Bearer ${Q}`,...z}})).json();if(E.ok!==!0){let G=E.error??"unknown";if(G==="invalid_auth"&&Q.startsWith("xoxc-")&&!$)throw Error(`Desktop app token (xoxc-) is not accepted by the public Slack API.
|
|
18
|
+
Replace it with an xoxp- user token:
|
|
19
|
+
slack workspace set-token <name> <xoxp-token>`);throw Error(`Slack error on ${J}: ${G}`)}return E}function GQ(Q,J,Z,$){let z=new URLSearchParams(Z).toString();return zQ(Q,`${J}?${z}`,{method:"GET"},$)}async function hJ(Q,J){try{let Z=await GQ(Q,"users.info",{user:J}),$=Z.user?.profile?.display_name;if($&&$.length>0)return $;return Z.user?.real_name??Z.user?.name??J}catch{return J}}async function A(Q,J,Z){let $=J,z=[],_=$,E=0;while(!0){let G=_.indexOf("<@U",E);if(G===-1)break;let W=_.slice(G+2),X=W.search(/[>|]/),B=X===-1?W:W.slice(0,X);if(B.length>=9)z.push(B);E=G+1}for(let G of z){if(!Z.has(G))Z.set(G,await hJ(Q,G));let W=Z.get(G)??G;$=$.replaceAll(`<@${G}>`,`@${W}`),$=$.replace(new RegExp(`<@${G}\\|[^>]*>`,"g"),`@${W}`)}return $}function b(Q){return Q.replace(/<!date\^(\d+)\^[^|>]*(?:\|([^>]*))?>/g,(J,Z,$)=>{let z=Number(Z);if(!Number.isFinite(z))return $??"date";let _=new Date(z*1000);if(Number.isNaN(_.getTime()))return $??"date";let E=["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],G=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],W=E[_.getUTCDay()]??"",X=G[_.getUTCMonth()]??"";return`${W}, ${X} ${String(_.getUTCDate()).padStart(2,"0")}, ${_.getUTCFullYear()}`})}function pJ(Q,J=new Date){let Z=new Date(Q*1000),$=_J(J),z=_J(new Date(J.getTime()-86400000)),_=_J(Z);if(_===$)return"Today";if(_===z)return"Yesterday";let E=Z.toLocaleDateString("en-US",{weekday:"long"}),G=Z.toLocaleDateString("en-US",{month:"short"});return`${E}, ${G} ${String(Z.getDate()).padStart(2,"0")}`}function _J(Q){return`${Q.getFullYear()}-${Q.getMonth()}-${Q.getDate()}`}function mJ(Q){let J=new Date(Q*1000);return`${String(J.getHours()).padStart(2,"0")}:${String(J.getMinutes()).padStart(2,"0")}`}function kJ(Q){let J=new Date(Q*1000),Z=J.getFullYear(),$=String(J.getMonth()+1).padStart(2,"0"),z=String(J.getDate()).padStart(2,"0"),_=String(J.getHours()).padStart(2,"0"),E=String(J.getMinutes()).padStart(2,"0");return`${Z}-${$}-${z} ${_}:${E}`}function y(Q){if(!cJ(Q))return;let J=_Q(Q,"utf8");for(let Z of J.split(`
|
|
20
|
+
`)){let $=Z.trim();if(!$||$.startsWith("#"))continue;let z=$.indexOf("=");if(z===-1)continue;let _=$.slice(0,z).trim(),E=$.slice(z+1).trim();if(E.startsWith('"')&&E.endsWith('"')||E.startsWith("'")&&E.endsWith("'"))E=E.slice(1,-1);if(!(_ in process.env))process.env[_]=E}}function YQ(Q){EQ(Q,{recursive:!0});let J=C(Q,".gitignore");if(!cJ(J))XQ(J,`*
|
|
21
|
+
`)}function BQ(){y(C(iJ(),".slack-cli",".env.local")),y(C(iJ(),".config/slack-cli",".env")),y(C(process.cwd(),".slack-cli",".env.local")),y(C(process.cwd(),".env.local")),y(C(process.cwd(),".env"))}function OQ(Q){return WQ("sha256").update(Q).digest("hex")}function q(Q){return Q&&typeof Q==="object"&&!Array.isArray(Q)?Q:{}}function U(Q){return Array.isArray(Q)?Q:[]}function n(Q){return Number(Q.ts??0)}async function EJ(Q,J,Z){let $=J.user;if(typeof $==="string"){if(!Z.has($))Z.set($,await GJ(Q,$));return Z.get($)??$}return typeof J.username==="string"?J.username:"bot"}function r(Q){let J=new Date(Q*1000),Z=J.getUTCFullYear(),$=String(J.getUTCMonth()+1).padStart(2,"0"),z=String(J.getUTCDate()).padStart(2,"0"),_=String(J.getUTCHours()).padStart(2,"0"),E=String(J.getUTCMinutes()).padStart(2,"0"),G=String(J.getUTCSeconds()).padStart(2,"0");return`${Z}-${$}-${z} ${_}:${E}:${G}`}async function dJ(Q,J,Z){let $=n(J),z=r($),_="?",E="?";if(typeof J.user==="string"){let B=J.user,Y=B,O="@"+B;if(!Z.has(Y)||!Z.has(O)){let[K,V]=await zJ(Q,B);Z.set(Y,K),Z.set(O,V)}_=Z.get(Y)??B,E=Z.get(O)??B}else if(typeof J.username==="string")_=J.username,E=J.username;let G=typeof J.text==="string"?J.text:"",X=b(await A(Q,G,Z)).split(`
|
|
22
|
+
`).join(" \u21B5 ");return`[${z}] <${_}|@${E}> ${X}`}async function VQ(Q,J,Z){let $=await N(Q,J),z=await x(Q,$,Z),_=U(z.messages).map(q),E=new Map;for(let G of _.reverse())console.log(await dJ(Q,G,E))}async function KQ(Q,J,Z,$){let z=await N(Q,J),_=await QJ(Q,z,Z,$),E=U(_.messages).map(q),G=new Map;for(let W of E)console.log(await dJ(Q,W,G))}async function HQ(Q){let J=await l(Q),Z=U(J.channels).map(q).filter((z)=>z.is_member===!0).sort((z,_)=>Number(_.updated??0)-Number(z.updated??0)).slice(0,10),$=new Map;for(let z of Z){let _=String(z.id??""),E=typeof z.name==="string"?z.name:typeof z.user==="string"?z.user:_,G=await x(Q,_,5),W=U(G.messages).map(q).filter((X)=>X.subtype===void 0||X.subtype===null).filter((X)=>{let B=typeof X.text==="string"?X.text:"";return B.length>0&&!B.startsWith("<@")}).slice(0,3);if(W.length===0)continue;console.log(`\u2500\u2500 #${E} \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500`);for(let X of W){let B=await EJ(Q,X,$),Y=(typeof X.text==="string"?X.text:"").split(`
|
|
23
|
+
`)[0]??"",O=await A(Q,Y,$);console.log(` ${B}: ${O}`)}}}async function FQ(Q,J){let Z=await SJ(Q,"to:me"),$=U(c(Z,["messages","matches"])).map(q),z=new Map,_="";for(let E of $.slice(0,J)){let G=n(E),W=pJ(G);if(W!==_){if(_!=="")console.log("");console.log(` ${W}`),console.log(" \u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504\u2504"),_=W}let X=q(E.channel),B=X.is_im===!0,Y=typeof X.name==="string"?X.name:"dm",O;if(B&&Y.startsWith("U")){if(!z.has(Y))z.set(Y,await GJ(Q,Y));O=`@${z.get(Y)??Y}`}else if(B)O=`@${Y}`;else O=`#${Y}`;let K=await EJ(Q,E,z),V=typeof E.text==="string"?E.text:"",F=(b(await A(Q,V,z)).split(`
|
|
24
|
+
`)[0]??"").slice(0,80);console.log(` ${B?"\uD83D\uDCAC":"\uD83D\uDD14"} ${O} ${mJ(G)}`),console.log(` ${K}: ${F}`)}}async function qQ(Q,J,Z,$){let z=await l(Q),_=U(z.channels).map(q).filter((E)=>$||E.is_member===!0).filter((E)=>{if(!Z)return!0;return(typeof E.name==="string"?E.name:"").toLowerCase().includes(Z.toLowerCase())}).sort((E,G)=>Number(G.updated??0)-Number(E.updated??0)).slice(0,J);for(let E of _){let G=String(E.id??""),W=typeof E.name==="string"?E.name:typeof E.user==="string"?E.user:G,X=E.is_im===!0,B=E.is_mpim===!0,Y=X||B?"@":"#",O=E.is_member===!0?"":" (not joined)";console.log(`${Y}${W} ${G}${O}`)}}async function MQ(Q,J,Z){let $=await vJ(Q,J,Z);console.log(JSON.stringify($,null,2))}async function jQ(Q,J,Z,$){let z=Math.floor(Date.now()/1000)-J*86400,_=await l(Q),E=U(_.channels).map(q).filter((B)=>B.is_member===!0&&B.is_im!==!0&&B.is_mpim!==!0).filter((B)=>{if(!$)return!0;return(typeof B.name==="string"?B.name:"").toLowerCase().includes($.toLowerCase())}).sort((B,Y)=>Number(Y.updated??0)-Number(B.updated??0)),G=new Map,W=0,X=0;for(let B of E){let Y=String(B.id??""),O=typeof B.name==="string"?B.name:Y,K;try{K=await x(Q,Y,Z)}catch(H){console.error(` SKIP #${O}: ${H.message}`);continue}let V=U(K.messages).map(q).filter((H)=>(H.subtype===void 0||H.subtype===null)&&n(H)>=z);if(V.length===0)continue;X+=1,W+=V.length,console.log(`## #${O} (${V.length} msgs)
|
|
25
|
+
`);for(let H of[...V].reverse()){let F=await EJ(Q,H,G),M=typeof H.text==="string"?H.text:"",u=b(await A(Q,M,G)).split(`
|
|
26
|
+
`).join(" \u21B5 ");console.log(`[${kJ(n(H))}] ${F}: ${u}`)}console.log("")}console.error(`Dumped ${W} messages across ${X} channels (cutoff: ${J}d)`)}function UQ(Q){let J=[];function Z($){if(typeof $==="string"){J.push($);return}if(Array.isArray($)){for(let z of $)Z(z);return}if($&&typeof $==="object"){let z=$;if(typeof z.text==="string"){J.push(z.text);return}if(Array.isArray(z.elements))Z(z.elements);else if(Array.isArray(z.content))Z(z.content)}}for(let $ of Q)Z($);return J.join("").trim()}async function lJ(Q,J,Z){let $=new Map;return async(z)=>{if(!z)return"(unknown)";if($.has(z))return $.get(z);try{let _=await fJ(Q,z,J),E=q(_.channel),G;if(E.is_im===!0){let W=typeof E.user==="string"?E.user:"";if(!W||W===Z)G="@self";else{let[X]=await zJ(Q,W);G=`@${X}`}}else G=`#${typeof E.name==="string"?E.name:z}`;return $.set(z,G),G}catch{return z}}}function XJ(Q){let J=q(U(Q.destinations)[0]);return String(J.channel_id??Q.channel_id??Q.channel??"")}function t(Q){return UQ(U(Q.blocks))||(typeof Q.text==="string"?Q.text:"(no text)")}async function DQ(Q,J,Z=!1){let $=await I(Q,J),z=U($.drafts??$.messages??[]).map(q).filter((X)=>X.is_deleted!==!0),_=Z?z:z.filter((X)=>X.is_sent!==!0);if(_.length===0){console.log(Z?"No drafts.":"No pending drafts. Run with --all to include sent.");return}let E="";try{({userId:E}=await WJ(Q,J))}catch{}let G=await lJ(Q,J,E),W=new Map;for(let X of _){let B=XJ(X),Y=t(X),O=Number(X.date_created??X.date_create??0),K=O?r(O):"?",V=await G(B),H=typeof X.id==="string"?X.id:"",F=X.is_sent===!0?" [SENT]":"",M=b(await A(Q,Y,W));console.log(`\u2500\u2500 ${H} ${V} [${K}]${F}`);for(let j of M.split(`
|
|
27
|
+
`))console.log(` ${j}`)}}async function LQ(Q,J,Z){let $=await I(Q,J),z=U($.drafts).map(q).find((V)=>String(V.id)===Z);if(!z)console.error(`Draft not found: ${Z}`),process.exit(1);let _="";try{({userId:_}=await WJ(Q,J))}catch{}let E=await lJ(Q,J,_),G=XJ(z),W=t(z),X=Number(z.date_created??0),B=String(z.last_updated_ts??"?"),Y=await E(G),K=b(await A(Q,W,new Map));console.log(`id: ${z.id}`),console.log(`channel: ${Y} (${G})`),console.log(`created: ${X?r(X):"?"}`),console.log(`updated: ${r(Number(B.split(".")[0]))}`),console.log(`status: ${z.is_sent===!0?"sent":"pending"}`),console.log("---"),console.log(K)}function f(...Q){return OQ(Q.join(`
|
|
28
|
+
`)).slice(0,4)}function g(Q,J,Z){for(let $ of Z)console.log($);if(Q!==void 0)console.error(`Code mismatch (got ${Q}, expected ${J})`);console.error(`Rerun with --code=${J}`),process.exit(1)}function RQ(Q){let J=$J(Q);if(J)return J.ts?{ref:J.channel,ts:J.ts}:{ref:J.channel};if(Q.startsWith("#")||Q.startsWith("@")){let Z=Q.indexOf(":");if(Z>0){let $=Q.slice(Z+1);if(/^\d{10}\.\d{6}$/.test($))return{ref:Q.slice(0,Z),ts:$}}}return{ref:Q}}async function TQ(Q,J){let{ref:Z,ts:$}=RQ(J.target);if(!$)console.error("Error: target must embed a message ts (e.g. #chan:1700000000.000100 or a Slack permalink URL)"),process.exit(2);let z;if(J.channelId)z=J.channelId;else z=await N(Q,Z);let _=await QJ(Q,z,$,1),G=U(_.messages).map(q).find((Y)=>String(Y.ts)===$);if(!G)console.error(`Message not found at ts=${$} in channel ${z}`),process.exit(1);let W=typeof G.text==="string"?G.text:"",X=f(W,J.newText);if(J.code!==X)g(J.code,X,["\u2500\u2500\u2500 Original message \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",...W.split(`
|
|
29
|
+
`).map((Y)=>` ${Y}`),"\u2500\u2500\u2500 Replacing with \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",...J.newText.split(`
|
|
30
|
+
`).map((Y)=>` ${Y}`),"\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"]);let B=await xJ(Q,z,$,J.newText);console.log(`\u2713 Edited (ts: ${B})`)}async function NQ(Q,J){let Z;if(J.channelId)Z=J.channelId;else if(J.userId)Z=await ZJ(Q,J.userId);else if(J.target.startsWith("#")||J.target.startsWith("@"))Z=await N(Q,J.target);else console.error(`Error: target must be #channel-name or @username (got: ${J.target})`),console.error("Use --channel-id=<ID> or --user-id=<ID> to send by raw ID."),process.exit(1);let $=await x(Q,Z,1),z=U($.messages).map(q).filter((X)=>X.subtype===void 0||X.subtype===null)[0],_=typeof z?.text==="string"?z.text:"",E=typeof z?.user==="string"?z.user:"?",G=f(_,J.message);if(J.code!==G)g(J.code,G,["\u2500\u2500\u2500 Last message in channel \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",` ${E}: ${_.split(`
|
|
31
|
+
`)[0]?.slice(0,100)??"(empty)"}`,"\u2500\u2500\u2500 Sending \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",` To: ${J.target}${J.thread?` (thread ${J.thread})`:""}`,` Message: ${J.message}`,"\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"]);let W=await bJ(Q,Z,J.message,J.thread);console.log(`\u2713 Sent (ts: ${W})`)}async function AQ(Q,J){let{statSync:Z,existsSync:$}=await import("fs"),{basename:z}=await import("path");if(!$(J.filePath))console.error(`Error: file not found: ${J.filePath}`),process.exit(1);let _=Z(J.filePath),E;if(J.channelId)E=J.channelId;else if(J.userId)E=await ZJ(Q,J.userId);else if(J.target.startsWith("#")||J.target.startsWith("@"))E=await N(Q,J.target);else console.error(`Error: target must be #channel-name or @username (got: ${J.target})`),process.exit(1);let G=z(J.filePath),W=J.title??G,X=_.size<1024?`${_.size} B`:_.size<1048576?`${(_.size/1024).toFixed(1)} KB`:`${(_.size/1048576).toFixed(1)} MB`,B=f(E,J.filePath,W);if(J.code!==B)g(J.code,B,["\u2500\u2500\u2500 Uploading file \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",` To: ${J.target}${J.thread?` (thread ${J.thread})`:""}`,` File: ${J.filePath}`,` Title: ${W}`,` Size: ${X}`,"\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"]);let Y={title:W};if(J.thread!==void 0)Y.threadTs=J.thread;if(J.comment!==void 0)Y.initialComment=J.comment;let{fileId:O,permalink:K}=await uJ(Q,E,J.filePath,Y);console.log(`\u2713 Uploaded (file_id: ${O}${K?`, url: ${K}`:""})`)}function P(){console.error(["Usage: slack [--workspace=<name>] <command> [args]","Commands:"," msgs [<#channel|@user|url>] [-n|--limit N]"," thread <#channel|@user|url> <ts> [-n|--limit N]"," channels [-n|--limit N] [--filter STR] [--all]"," news [-l|--limit N]"," search <query> [-n|--count N]"," drafts [--all] list pending drafts (--all includes sent)"," drafts new <#channel|@user> <text>"," drafts get <draft-id>"," drafts edit <draft-id> [--code=XXXX] <new-text>"," drafts delete <draft-id> [--code=XXXX]"," send <target> <message> [--thread TS] [--code XXXX] [--channel-id ID] [--user-id ID]"," edit <#chan:ts|@user:ts|url> <new-text> [--code XXXX] [--channel-id ID]"," upload <target> <file> [--title TEXT] [--thread TS] [--comment TEXT] [--code XXXX] [--channel-id ID] [--user-id ID]"," dump [-d|--days N] [-l|--limit N] [-f|--filter STR]"," workspace ls|list"," workspace import (auto-import from Slack desktop app)"," workspace add <name> <token>"," workspace set-token <name> <token>"," workspace set-cookie <name> <xoxd> (store xoxd cookie for draft API)"," workspace use <name>"," workspace remove <name>"," workspace current"].join(`
|
|
32
|
+
`)),process.exit(2)}async function PQ(Q,J){switch(Q){case"list":case"ls":{let Z=e();if(Z.length===0){console.log("No workspaces configured."),console.log("Import from the Slack desktop app: slack workspace import"),console.log("Or add manually: slack workspace add <name> <token>");return}for(let{name:$,profile:z,current:_}of Z)console.log(`${_?"* ":" "}${$} ${z.team} (${z.user}) ${z.url??""}`);return}case"import":{console.error("Scanning Slack desktop app...");let Z=await AJ();if(Z.length===0){console.error("No sessions found. Make sure Slack is installed and you have logged in.");return}for(let $ of Z){let z=$.teamName??$.teamId;console.error(` Found: ${z} ${$.url??""}`);let _=z.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,""),E={token:$.token,team:$.teamName??$.teamId,teamId:$.teamId,url:$.url??"",user:""};if($.cookie)E.cookie=$.cookie;k(_,E);let G=$.cookie?" + xoxd cookie":"";console.log(`Added workspace "${_}": ${z}${G}`)}console.log(`
|
|
33
|
+
Note: desktop app tokens (xoxc-) are internal Slack tokens.`),console.log("If API calls fail, replace with an xoxp- token:"),console.log(" slack workspace set-token <name> <xoxp-token>"),console.log(`
|
|
34
|
+
Done. Run: slack workspace ls`);return}case"add":{let[Z,$]=J;if(!Z||!$)console.error("Usage: slack workspace add <name> <token>"),process.exit(2);console.error("Verifying token...");let z=await JJ($);k(Z,{token:$,...z}),console.log(`Added workspace "${Z}": ${z.team} (${z.user})`);return}case"set-token":{let[Z,$]=J;if(!Z||!$)console.error("Usage: slack workspace set-token <name> <token>"),console.error(" Updates the token for an existing workspace profile."),process.exit(2);console.error("Verifying token...");let z=await JJ($);k(Z,{token:$,...z}),console.log(`Updated workspace "${Z}": ${z.team} (${z.user})`);return}case"set-cookie":{let[Z,$]=J;if(!Z||!$)console.error("Usage: slack workspace set-cookie <name> <xoxd-value>"),console.error(" Stores the xoxd session cookie for draft API access."),console.error(" Get it from: DevTools \u2192 Application \u2192 Cookies \u2192 slack.com \u2192 d"),process.exit(2);let z=$.startsWith("d=")?$.slice(2):$;qJ(Z,z),console.log(`Cookie set for workspace "${Z}". Run: slack drafts`);return}case"use":{let Z=J.includes("-g"),$=J.find((_)=>_!=="-g");if(!$)console.error("Usage: slack workspace use [-g] <name>"),console.error(" -g write to ~/.slack-cli/workspace (global)"),console.error(" default: write to .slack-cli/workspace (local cwd)"),process.exit(2);if(!Z)YQ(C(process.cwd(),".slack-cli"));if(jJ($,Z),console.log(`Switched to workspace "${$}" ${Z?"globally (~/.slack-cli/workspace)":"locally (.slack-cli/workspace)"}`),!Z)console.log("Tip: add .slack-cli/ to your .gitignore");return}case"remove":{let[Z]=J;if(!Z)console.error("Usage: slack workspace remove <name>"),process.exit(2);MJ(Z),console.log(`Removed workspace "${Z}"`);return}case"current":{let $=e().find((z)=>z.current);if(!$){console.log("No workspace selected");return}console.log(`${$.name} ${$.profile.team} (${$.profile.user})`);return}default:P()}}async function CQ(){BQ();let Q=process.argv.slice(2),J,Z=[];for(let G of Q){let W=G.match(/^--workspace=(.+)$/);if(W)J=W[1];else Z.push(G)}let[$,...z]=Z;if($==="workspace"){await PQ(z[0]??"",z.slice(1));return}let _=UJ(J),E=DJ(J);switch($){case"msgs":{let{values:G,positionals:W}=L({args:z,allowPositionals:!0,options:{limit:{type:"string",short:"n",default:"20"}},strict:!0});if(W[0])await VQ(_,W[0],Number(G.limit));else await HQ(_);return}case"thread":{let{values:G,positionals:W}=L({args:z,allowPositionals:!0,options:{limit:{type:"string",short:"n",default:"100"}},strict:!0}),X=W[0],B=W[1];if(!X||!B)P();await KQ(_,X,B,Number(G.limit));return}case"channels":{let{values:G}=L({args:z,options:{limit:{type:"string",short:"n",default:"200"},filter:{type:"string",short:"f"},all:{type:"boolean"}},strict:!0});await qQ(_,Number(G.limit),G.filter,G.all);return}case"news":{let{values:G}=L({args:z,options:{limit:{type:"string",short:"l",default:"20"}},strict:!0});await FQ(_,Number(G.limit));return}case"drafts":{let G=z[0];if(G==="new"||G==="save"){let W=z.slice(1),X=W[0],B=W.slice(1).join(" ");if(!X||!B)console.error("Usage: slack drafts new <#channel|@user> <text>"),process.exit(2);let Y=await N(_,X,E),O=await IJ(_,Y,B,E);console.log(`\u2713 Draft created (id: ${q(O.draft).id??"?"})`)}else if(G==="get"){let W=z[1];if(!W)console.error("Usage: slack drafts get <draft-id>"),process.exit(2);await LQ(_,E,W)}else if(G==="edit"||G==="update"){let W=z[1],X=z.find((j)=>j.startsWith("--code="))?.slice(7),Y=z.slice(2).filter((j)=>!j.startsWith("--code=")).join(" ");if(!W||!Y)console.error("Usage: slack drafts edit <draft-id> <new-text>"),process.exit(2);let O=await I(_,E),K=U(O.drafts).map(q).find((j)=>String(j.id)===W);if(!K)console.error(`Draft not found: ${W}`),process.exit(1);let V=t(K),H=f(V,Y);if(X!==H)g(X,H,["\u2500\u2500\u2500 Current draft content \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",...V.split(`
|
|
35
|
+
`).map((j)=>` ${j}`),"\u2500\u2500\u2500 Replacing with \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",...Y.split(`
|
|
36
|
+
`).map((j)=>` ${j}`),"\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"]);let F=XJ(K),M=await yJ(_,W,F,Y,E);console.log(`\u2713 Draft updated (id: ${q(M.draft).id??"?"})`)}else if(G==="delete"||G==="rm"){let W=z[1],X=z.find((V)=>V.startsWith("--code="))?.slice(7);if(!W)console.error("Usage: slack drafts delete <draft-id>"),process.exit(2);let B=await I(_,E),Y=U(B.drafts).map(q).find((V)=>String(V.id)===W);if(!Y)console.error(`Draft not found: ${W}`),process.exit(1);let O=t(Y),K=f(W,O);if(X!==K)g(X,K,["\u2500\u2500\u2500 Deleting draft \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",` id: ${W}`,...O.split(`
|
|
37
|
+
`).map((V)=>` ${V}`),"\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"]);await gJ(_,W,E),console.log(`\u2713 Draft deleted (id: ${W})`)}else{let W=z.includes("--all")||z.includes("-a");await DQ(_,E,W)}return}case"search":{let{values:G,positionals:W}=L({args:z,allowPositionals:!0,options:{count:{type:"string",short:"n",default:"100"}},strict:!0}),X=W[0];if(!X)P();await MQ(_,X,Number(G.count));return}case"send":{let{values:G,positionals:W}=L({args:z,allowPositionals:!0,options:{thread:{type:"string"},code:{type:"string"},"channel-id":{type:"string"},"user-id":{type:"string"}},strict:!0}),X=W[0],B=W[1];if(!X||!B)P();let Y={target:X,message:B};if(G.thread!==void 0)Y.thread=G.thread;if(G.code!==void 0)Y.code=G.code;if(G["channel-id"]!==void 0)Y.channelId=G["channel-id"];if(G["user-id"]!==void 0)Y.userId=G["user-id"];await NQ(_,Y);return}case"edit":{let{values:G,positionals:W}=L({args:z,allowPositionals:!0,options:{code:{type:"string"},"channel-id":{type:"string"}},strict:!0}),X=W[0],B=W[1];if(!X||!B)P();let Y={target:X,newText:B};if(G.code!==void 0)Y.code=G.code;if(G["channel-id"]!==void 0)Y.channelId=G["channel-id"];await TQ(_,Y);return}case"upload":{let{values:G,positionals:W}=L({args:z,allowPositionals:!0,options:{title:{type:"string"},thread:{type:"string"},comment:{type:"string"},code:{type:"string"},"channel-id":{type:"string"},"user-id":{type:"string"}},strict:!0}),X=W[0],B=W[1];if(!X||!B)P();let Y={target:X,filePath:B};if(G.title!==void 0)Y.title=G.title;if(G.thread!==void 0)Y.thread=G.thread;if(G.comment!==void 0)Y.comment=G.comment;if(G.code!==void 0)Y.code=G.code;if(G["channel-id"]!==void 0)Y.channelId=G["channel-id"];if(G["user-id"]!==void 0)Y.userId=G["user-id"];await AQ(_,Y);return}case"dump":{let{values:G}=L({args:z,options:{days:{type:"string",short:"d",default:"7"},limit:{type:"string",short:"l",default:"200"},filter:{type:"string",short:"f"}},strict:!0});await jQ(_,Number(G.days),Number(G.limit),G.filter);return}default:P()}}CQ().catch((Q)=>{let J=Q instanceof Error?Q.message:String(Q);console.error(`Error: ${J}`),process.exit(1)});
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "slack-term",
|
|
3
|
+
"version": "1.7.0",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "https://github.com/snomiao/slack-cli.git"
|
|
7
|
+
},
|
|
8
|
+
"devDependencies": {
|
|
9
|
+
"@semantic-release/commit-analyzer": "^13",
|
|
10
|
+
"@semantic-release/github": "^12",
|
|
11
|
+
"@semantic-release/npm": "^13",
|
|
12
|
+
"@semantic-release/release-notes-generator": "^14",
|
|
13
|
+
"@types/bun": "latest",
|
|
14
|
+
"@types/node": "^22",
|
|
15
|
+
"@vitest/coverage-v8": "^4.1.4",
|
|
16
|
+
"semantic-release": "^24",
|
|
17
|
+
"typescript": "^5.6",
|
|
18
|
+
"vitest": "^4.1.4"
|
|
19
|
+
},
|
|
20
|
+
"bin": {
|
|
21
|
+
"slack": "./ts/cli.ts"
|
|
22
|
+
},
|
|
23
|
+
"description": "Slack CLI — read activity/news/mentions, search, send messages with a confirm-hash safety gate.",
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"ts/",
|
|
29
|
+
"dist/",
|
|
30
|
+
"README.md",
|
|
31
|
+
"SKILL.md",
|
|
32
|
+
"LICENSE"
|
|
33
|
+
],
|
|
34
|
+
"homepage": "https://github.com/snomiao/slack-cli",
|
|
35
|
+
"keywords": [
|
|
36
|
+
"slack",
|
|
37
|
+
"cli",
|
|
38
|
+
"messaging",
|
|
39
|
+
"agent",
|
|
40
|
+
"terminal"
|
|
41
|
+
],
|
|
42
|
+
"license": "MIT",
|
|
43
|
+
"overrides": {
|
|
44
|
+
"@semantic-release/npm": "^13",
|
|
45
|
+
"@semantic-release/github": "^12"
|
|
46
|
+
},
|
|
47
|
+
"publishConfig": {
|
|
48
|
+
"access": "public"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"dev": "bun run ts/cli.ts",
|
|
52
|
+
"build": "bun build ts/cli.ts --target=node --outfile dist/cli.js --minify",
|
|
53
|
+
"typecheck": "tsc --noEmit",
|
|
54
|
+
"test": "vitest run --coverage",
|
|
55
|
+
"test:watch": "vitest",
|
|
56
|
+
"test:parity": "bash tests/parity.sh",
|
|
57
|
+
"record": "bun run tests/record.ts",
|
|
58
|
+
"anonymize": "bun run tests/anonymize.ts",
|
|
59
|
+
"prepublishOnly": "bun run typecheck && bun run build"
|
|
60
|
+
},
|
|
61
|
+
"type": "module"
|
|
62
|
+
}
|