@ruminaider/slack-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/slack.js ADDED
@@ -0,0 +1,348 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { getAuthStatus, getCredentials, login, logout, importCredentials, parseCurl } from "../lib/auth.js";
4
+ import * as api from "../lib/api.js";
5
+ import { CLI_NAME, PACKAGE_VERSION } from "../lib/config.js";
6
+
7
+ const BOOLEAN_FLAGS = new Set([
8
+ "--private",
9
+ "--include-archived",
10
+ "--inclusive",
11
+ "--broadcast",
12
+ "--json",
13
+ "--help",
14
+ "-h",
15
+ "--version",
16
+ "-v",
17
+ ]);
18
+
19
+ const VALUE_FLAGS = new Set([
20
+ "--token",
21
+ "--cookie",
22
+ "--cookie-ds",
23
+ "--curl",
24
+ "--host",
25
+ "--team",
26
+ "--channel",
27
+ "--user",
28
+ "--limit",
29
+ "--cursor",
30
+ "--oldest",
31
+ "--latest",
32
+ "--page",
33
+ "--sort",
34
+ "--sort-dir",
35
+ "--types",
36
+ "--ts",
37
+ "--thread-ts",
38
+ "--text",
39
+ "--blocks",
40
+ "--name",
41
+ "--at",
42
+ "--file",
43
+ ]);
44
+
45
+ const ALL_FLAGS = new Set([...BOOLEAN_FLAGS, ...VALUE_FLAGS]);
46
+
47
+ function parseArgs(argv) {
48
+ const positionals = [];
49
+ const values = new Map();
50
+ for (let i = 0; i < argv.length; i++) {
51
+ const arg = argv[i];
52
+ // Everything after a bare `--` is positional, so message text may start
53
+ // with a dash: `message send C -- "-1 from baseline"`.
54
+ if (arg === "--") {
55
+ positionals.push(...argv.slice(i + 1));
56
+ break;
57
+ }
58
+ if (!arg.startsWith("-") || arg === "-") {
59
+ positionals.push(arg);
60
+ continue;
61
+ }
62
+ const eq = arg.indexOf("=");
63
+ if (eq !== -1) {
64
+ const flag = arg.slice(0, eq);
65
+ if (!VALUE_FLAGS.has(flag)) throw new Error(`Unknown or non-value flag: ${flag}`);
66
+ values.set(flag, arg.slice(eq + 1));
67
+ continue;
68
+ }
69
+ if (BOOLEAN_FLAGS.has(arg)) {
70
+ values.set(arg, true);
71
+ continue;
72
+ }
73
+ if (VALUE_FLAGS.has(arg)) {
74
+ const next = argv[i + 1];
75
+ if (next === undefined || ALL_FLAGS.has(next)) throw new Error(`Flag ${arg} requires a value`);
76
+ values.set(arg, next);
77
+ i++;
78
+ continue;
79
+ }
80
+ throw new Error(`Unknown flag: ${arg}`);
81
+ }
82
+ return { positionals, values };
83
+ }
84
+
85
+ function out(data) {
86
+ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
87
+ }
88
+
89
+ function fail(message, code = 1) {
90
+ process.stderr.write(`${message}\n`);
91
+ process.exit(code);
92
+ }
93
+
94
+ function need(value, label) {
95
+ if (value === undefined || value === null || value === "") {
96
+ throw new Error(`Missing required ${label}`);
97
+ }
98
+ return value;
99
+ }
100
+
101
+ const HELP = `${CLI_NAME} ${PACKAGE_VERSION} — Slack from the terminal, authenticated as you.
102
+
103
+ Auth (no app, no OAuth; uses your Slack desktop session):
104
+ ${CLI_NAME} auth login Extract + persist credentials from the Slack app
105
+ ${CLI_NAME} auth status Show authenticated workspaces
106
+ ${CLI_NAME} auth import --curl '<curl>' Import from a copied devtools cURL
107
+ ${CLI_NAME} auth import --token xoxc-... --cookie xoxd-...
108
+ ${CLI_NAME} auth logout
109
+
110
+ Channels:
111
+ ${CLI_NAME} channel list [--types ...] [--limit N] [--include-archived]
112
+ ${CLI_NAME} channel info <channel>
113
+ ${CLI_NAME} channel history <channel> [--limit N] [--oldest ts] [--latest ts]
114
+ ${CLI_NAME} channel members <channel>
115
+ ${CLI_NAME} channel join <channel>
116
+ ${CLI_NAME} channel create <name> [--private]
117
+
118
+ Messages:
119
+ ${CLI_NAME} message send <channel> <text> [--thread-ts ts] [--broadcast]
120
+ ${CLI_NAME} message reply <channel> <thread-ts> <text>
121
+ ${CLI_NAME} message update <channel> <ts> <text>
122
+ ${CLI_NAME} message delete <channel> <ts>
123
+ ${CLI_NAME} message schedule <channel> <text> --at <unix-ts>
124
+ ${CLI_NAME} thread read <channel> <thread-ts>
125
+
126
+ Search:
127
+ ${CLI_NAME} search messages <query> [--limit N] [--sort score|timestamp]
128
+ ${CLI_NAME} search files <query>
129
+ ${CLI_NAME} search all <query>
130
+
131
+ People & reactions:
132
+ ${CLI_NAME} user list | user info <user> | user me
133
+ ${CLI_NAME} reaction add <channel> <ts> <emoji>
134
+ ${CLI_NAME} reaction remove <channel> <ts> <emoji>
135
+
136
+ Files, pins, canvases:
137
+ ${CLI_NAME} file list [--channel C] | file info <file>
138
+ ${CLI_NAME} pin list <channel> | pin add <channel> <ts> | pin remove <channel> <ts>
139
+ ${CLI_NAME} canvas list [--channel C] | canvas get <canvas-id>
140
+
141
+ Global: --team <name|id|host> to target a workspace. Output is JSON.
142
+ Text starting with a dash: put it after a bare '--', e.g. message send C0123 -- "-1 vs baseline".`;
143
+
144
+ async function creds(values) {
145
+ return getCredentials({
146
+ token: values.get("--token"),
147
+ cookie: values.get("--cookie"),
148
+ cookieDs: values.get("--cookie-ds"),
149
+ host: values.get("--host"),
150
+ team: values.get("--team"),
151
+ });
152
+ }
153
+
154
+ async function runAuth(sub, positionals, values) {
155
+ switch (sub) {
156
+ case "login":
157
+ case undefined:
158
+ return out(await login({ team: values.get("--team") }));
159
+ case "status":
160
+ return out(await getAuthStatus());
161
+ case "logout":
162
+ return out(await logout());
163
+ case "import": {
164
+ if (values.get("--curl")) {
165
+ const parsed = parseCurl(values.get("--curl"));
166
+ return out(await importCredentials({
167
+ ...parsed,
168
+ cookieDs: values.get("--cookie-ds") || parsed.cookieDs,
169
+ host: values.get("--host") || parsed.host,
170
+ }));
171
+ }
172
+ return out(await importCredentials({
173
+ token: values.get("--token"),
174
+ cookie: values.get("--cookie"),
175
+ cookieDs: values.get("--cookie-ds"),
176
+ host: values.get("--host"),
177
+ team: values.get("--team"),
178
+ }));
179
+ }
180
+ default:
181
+ throw new Error(`Unknown auth subcommand: ${sub}`);
182
+ }
183
+ }
184
+
185
+ async function runChannel(sub, p, values) {
186
+ const c = await creds(values);
187
+ const opts = {
188
+ types: values.get("--types"),
189
+ limit: values.get("--limit"),
190
+ cursor: values.get("--cursor"),
191
+ oldest: values.get("--oldest"),
192
+ latest: values.get("--latest"),
193
+ inclusive: values.get("--inclusive"),
194
+ includeArchived: values.get("--include-archived"),
195
+ private: values.get("--private"),
196
+ };
197
+ switch (sub) {
198
+ case "list": return out(await api.channelList(c, opts));
199
+ case "info": return out(await api.channelInfo(c, need(p[0] || values.get("--channel"), "channel")));
200
+ case "history": return out(await api.channelHistory(c, need(p[0] || values.get("--channel"), "channel"), opts));
201
+ case "members": return out(await api.channelMembers(c, need(p[0] || values.get("--channel"), "channel"), opts));
202
+ case "join": return out(await api.channelJoin(c, need(p[0] || values.get("--channel"), "channel")));
203
+ case "create": return out(await api.channelCreate(c, need(p[0] || values.get("--name"), "name"), opts));
204
+ default: throw new Error(`Unknown channel subcommand: ${sub}`);
205
+ }
206
+ }
207
+
208
+ async function runMessage(sub, p, values) {
209
+ const c = await creds(values);
210
+ const channel = () => need(p[0] || values.get("--channel"), "channel");
211
+ switch (sub) {
212
+ case "send":
213
+ return out(await api.messageSend(c, channel(), need(p[1] ?? values.get("--text"), "text"), {
214
+ threadTs: values.get("--thread-ts"),
215
+ broadcast: values.get("--broadcast"),
216
+ blocks: values.get("--blocks"),
217
+ }));
218
+ case "reply":
219
+ return out(await api.messageSend(c, channel(), need(p[2] ?? values.get("--text"), "text"), {
220
+ threadTs: need(p[1] || values.get("--thread-ts"), "thread-ts"),
221
+ broadcast: values.get("--broadcast"),
222
+ }));
223
+ case "update":
224
+ return out(await api.messageUpdate(c, channel(), need(p[1] || values.get("--ts"), "ts"), need(p[2] ?? values.get("--text"), "text"), {
225
+ blocks: values.get("--blocks"),
226
+ }));
227
+ case "delete":
228
+ return out(await api.messageDelete(c, channel(), need(p[1] || values.get("--ts"), "ts")));
229
+ case "schedule":
230
+ return out(await api.messageSchedule(c, channel(), need(p[1] ?? values.get("--text"), "text"), need(values.get("--at"), "--at"), {
231
+ threadTs: values.get("--thread-ts"),
232
+ }));
233
+ default: throw new Error(`Unknown message subcommand: ${sub}`);
234
+ }
235
+ }
236
+
237
+ async function runThread(sub, p, values) {
238
+ const c = await creds(values);
239
+ if (sub !== "read") throw new Error(`Unknown thread subcommand: ${sub}`);
240
+ return out(await api.threadRead(c, need(p[0] || values.get("--channel"), "channel"), need(p[1] || values.get("--thread-ts"), "thread-ts"), {
241
+ limit: values.get("--limit"),
242
+ cursor: values.get("--cursor"),
243
+ }));
244
+ }
245
+
246
+ async function runSearch(sub, p, values) {
247
+ const c = await creds(values);
248
+ const query = need(p[0] || values.get("--text"), "query");
249
+ const opts = { limit: values.get("--limit"), page: values.get("--page"), sort: values.get("--sort"), sortDir: values.get("--sort-dir") };
250
+ switch (sub) {
251
+ case "messages": return out(await api.searchMessages(c, query, opts));
252
+ case "files": return out(await api.searchFiles(c, query, opts));
253
+ case "all": return out(await api.searchAll(c, query, opts));
254
+ default: throw new Error(`Unknown search subcommand: ${sub}`);
255
+ }
256
+ }
257
+
258
+ async function runUser(sub, p, values) {
259
+ const c = await creds(values);
260
+ switch (sub) {
261
+ case "list": return out(await api.userList(c, { limit: values.get("--limit"), cursor: values.get("--cursor") }));
262
+ case "info": return out(await api.userInfo(c, need(p[0] || values.get("--user"), "user")));
263
+ case "me": {
264
+ const who = await api.authTest(c);
265
+ return out(await api.userInfo(c, who.user_id));
266
+ }
267
+ default: throw new Error(`Unknown user subcommand: ${sub}`);
268
+ }
269
+ }
270
+
271
+ async function runReaction(sub, p, values) {
272
+ const c = await creds(values);
273
+ const channel = need(p[0] || values.get("--channel"), "channel");
274
+ const ts = need(p[1] || values.get("--ts"), "ts");
275
+ const name = need(p[2] || values.get("--name"), "emoji");
276
+ if (sub === "add") return out(await api.reactionAdd(c, channel, ts, name));
277
+ if (sub === "remove") return out(await api.reactionRemove(c, channel, ts, name));
278
+ throw new Error(`Unknown reaction subcommand: ${sub}`);
279
+ }
280
+
281
+ async function runFile(sub, p, values) {
282
+ const c = await creds(values);
283
+ switch (sub) {
284
+ case "list": return out(await api.fileList(c, { channel: values.get("--channel"), user: values.get("--user"), limit: values.get("--limit"), page: values.get("--page") }));
285
+ case "info": return out(await api.fileInfo(c, need(p[0] || values.get("--file"), "file")));
286
+ default: throw new Error(`Unknown file subcommand: ${sub}`);
287
+ }
288
+ }
289
+
290
+ async function runPin(sub, p, values) {
291
+ const c = await creds(values);
292
+ const channel = need(p[0] || values.get("--channel"), "channel");
293
+ switch (sub) {
294
+ case "list": return out(await api.pinList(c, channel));
295
+ case "add": return out(await api.pinAdd(c, channel, need(p[1] || values.get("--ts"), "ts")));
296
+ case "remove": return out(await api.pinRemove(c, channel, need(p[1] || values.get("--ts"), "ts")));
297
+ default: throw new Error(`Unknown pin subcommand: ${sub}`);
298
+ }
299
+ }
300
+
301
+ async function runCanvas(sub, p, values) {
302
+ const c = await creds(values);
303
+ if (sub === "list") return out(await api.canvasList(c, { channel: values.get("--channel"), limit: values.get("--limit") }));
304
+ if (sub === "get") return out(await api.canvasGet(c, need(p[0], "canvas-id")));
305
+ throw new Error(`Unknown canvas subcommand: ${sub}`);
306
+ }
307
+
308
+ async function main() {
309
+ const argv = process.argv.slice(2);
310
+ let parsed;
311
+ try {
312
+ parsed = parseArgs(argv);
313
+ } catch (err) {
314
+ fail(err.message);
315
+ }
316
+ const { positionals, values } = parsed;
317
+
318
+ if (values.get("--version") || values.get("-v")) return out({ name: CLI_NAME, version: PACKAGE_VERSION });
319
+ const command = positionals[0];
320
+ if (!command || command === "help" || values.get("--help") || values.get("-h")) {
321
+ process.stdout.write(`${HELP}\n`);
322
+ return;
323
+ }
324
+
325
+ const sub = positionals[1];
326
+ const rest = positionals.slice(2);
327
+
328
+ try {
329
+ switch (command) {
330
+ case "auth": return await runAuth(sub, rest, values);
331
+ case "channel": return await runChannel(sub, rest, values);
332
+ case "message": return await runMessage(sub, rest, values);
333
+ case "thread": return await runThread(sub, rest, values);
334
+ case "search": return await runSearch(sub, rest, values);
335
+ case "user": return await runUser(sub, rest, values);
336
+ case "reaction": return await runReaction(sub, rest, values);
337
+ case "file": return await runFile(sub, rest, values);
338
+ case "pin": return await runPin(sub, rest, values);
339
+ case "canvas": return await runCanvas(sub, rest, values);
340
+ default:
341
+ fail(`Unknown command: ${command}. Run \`${CLI_NAME} help\`.`);
342
+ }
343
+ } catch (err) {
344
+ fail(err.message);
345
+ }
346
+ }
347
+
348
+ main();
package/lib/api.js ADDED
@@ -0,0 +1,247 @@
1
+ // Slack web API client for native-session (xoxc + xoxd) credentials.
2
+ //
3
+ // xoxc tokens only authenticate when paired with the xoxd `d` cookie, so every
4
+ // request sends the token in the form body and the cookie in the Cookie header,
5
+ // against the workspace host. Command wrappers take resolved credentials so
6
+ // this module never imports auth.js (no import cycle).
7
+
8
+ import { DEFAULT_API_HOST } from "./config.js";
9
+
10
+ class SlackApiError extends Error {
11
+ constructor(method, error, response) {
12
+ super(`Slack API ${method} failed: ${error}`);
13
+ this.name = "SlackApiError";
14
+ this.slackError = error;
15
+ this.response = response;
16
+ }
17
+ }
18
+
19
+ function apiHost(creds) {
20
+ return creds?.host || DEFAULT_API_HOST;
21
+ }
22
+
23
+ function buildBody(token, params) {
24
+ const body = new URLSearchParams();
25
+ body.set("token", token);
26
+ for (const [key, value] of Object.entries(params || {})) {
27
+ if (value === undefined || value === null || value === "") continue;
28
+ body.set(key, typeof value === "object" ? JSON.stringify(value) : String(value));
29
+ }
30
+ return body;
31
+ }
32
+
33
+ // Low-level call. Returns the parsed Slack response on ok:true, throws
34
+ // SlackApiError otherwise.
35
+ export async function webApiCall(method, params, creds) {
36
+ if (!creds?.token || !creds?.cookie) {
37
+ throw new Error("Missing Slack credentials (token + cookie).");
38
+ }
39
+ const url = `https://${apiHost(creds)}/api/${method}`;
40
+ // The xoxd `d` cookie value is stored and transmitted percent-encoded; send
41
+ // it verbatim. Re-encoding it (e.g. encodeURIComponent) corrupts the trailing
42
+ // `%3D` and Slack rejects the request with invalid_auth. The `d-s` companion
43
+ // (Enterprise Grid / SSO) is appended when present.
44
+ const cookieValue = creds.cookie.startsWith("xoxd-") ? creds.cookie : `xoxd-${creds.cookie}`;
45
+ const cookieHeader = creds.cookieDs ? `d=${cookieValue}; d-s=${creds.cookieDs}` : `d=${cookieValue}`;
46
+
47
+ const res = await fetch(url, {
48
+ method: "POST",
49
+ headers: {
50
+ "Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
51
+ Cookie: cookieHeader,
52
+ Accept: "application/json",
53
+ },
54
+ body: buildBody(creds.token, params),
55
+ });
56
+
57
+ if (!res.ok) {
58
+ throw new Error(`Slack API ${method} HTTP ${res.status}: ${await res.text()}`);
59
+ }
60
+ const data = await res.json();
61
+ if (!data.ok) {
62
+ throw new SlackApiError(method, data.error || "unknown_error", data);
63
+ }
64
+ return data;
65
+ }
66
+
67
+ const num = (v) => (v === undefined || v === null || v === "" ? undefined : Number(v));
68
+
69
+ // ─── auth ────────────────────────────────────────────────────
70
+ export function authTest(creds) {
71
+ return webApiCall("auth.test", {}, creds);
72
+ }
73
+
74
+ // ─── channels / conversations ────────────────────────────────
75
+ export function channelList(creds, options = {}) {
76
+ return webApiCall("conversations.list", {
77
+ types: options.types || "public_channel,private_channel,mpim,im",
78
+ limit: num(options.limit) ?? 200,
79
+ cursor: options.cursor,
80
+ exclude_archived: options.includeArchived ? false : true,
81
+ }, creds);
82
+ }
83
+
84
+ export function channelInfo(creds, channel) {
85
+ return webApiCall("conversations.info", { channel }, creds);
86
+ }
87
+
88
+ export function channelHistory(creds, channel, options = {}) {
89
+ return webApiCall("conversations.history", {
90
+ channel,
91
+ limit: num(options.limit) ?? 50,
92
+ cursor: options.cursor,
93
+ oldest: options.oldest,
94
+ latest: options.latest,
95
+ inclusive: options.inclusive ? true : undefined,
96
+ }, creds);
97
+ }
98
+
99
+ export function channelMembers(creds, channel, options = {}) {
100
+ return webApiCall("conversations.members", {
101
+ channel,
102
+ limit: num(options.limit) ?? 200,
103
+ cursor: options.cursor,
104
+ }, creds);
105
+ }
106
+
107
+ export function channelJoin(creds, channel) {
108
+ return webApiCall("conversations.join", { channel }, creds);
109
+ }
110
+
111
+ export function channelCreate(creds, name, options = {}) {
112
+ return webApiCall("conversations.create", {
113
+ name,
114
+ is_private: options.private ? true : undefined,
115
+ }, creds);
116
+ }
117
+
118
+ // ─── threads ─────────────────────────────────────────────────
119
+ export function threadRead(creds, channel, ts, options = {}) {
120
+ return webApiCall("conversations.replies", {
121
+ channel,
122
+ ts,
123
+ limit: num(options.limit) ?? 100,
124
+ cursor: options.cursor,
125
+ }, creds);
126
+ }
127
+
128
+ // ─── messages ────────────────────────────────────────────────
129
+ export function messageSend(creds, channel, text, options = {}) {
130
+ return webApiCall("chat.postMessage", {
131
+ channel,
132
+ text,
133
+ thread_ts: options.threadTs,
134
+ reply_broadcast: options.broadcast ? true : undefined,
135
+ blocks: options.blocks,
136
+ }, creds);
137
+ }
138
+
139
+ export function messageUpdate(creds, channel, ts, text, options = {}) {
140
+ return webApiCall("chat.update", { channel, ts, text, blocks: options.blocks }, creds);
141
+ }
142
+
143
+ export function messageDelete(creds, channel, ts) {
144
+ return webApiCall("chat.delete", { channel, ts }, creds);
145
+ }
146
+
147
+ export function messageSchedule(creds, channel, text, postAt, options = {}) {
148
+ return webApiCall("chat.scheduleMessage", {
149
+ channel,
150
+ text,
151
+ post_at: num(postAt),
152
+ thread_ts: options.threadTs,
153
+ blocks: options.blocks,
154
+ }, creds);
155
+ }
156
+
157
+ // ─── search ──────────────────────────────────────────────────
158
+ export function searchMessages(creds, query, options = {}) {
159
+ return webApiCall("search.messages", {
160
+ query,
161
+ count: num(options.limit) ?? 20,
162
+ page: num(options.page),
163
+ sort: options.sort,
164
+ sort_dir: options.sortDir,
165
+ }, creds);
166
+ }
167
+
168
+ export function searchFiles(creds, query, options = {}) {
169
+ return webApiCall("search.files", {
170
+ query,
171
+ count: num(options.limit) ?? 20,
172
+ page: num(options.page),
173
+ }, creds);
174
+ }
175
+
176
+ export function searchAll(creds, query, options = {}) {
177
+ return webApiCall("search.all", {
178
+ query,
179
+ count: num(options.limit) ?? 20,
180
+ page: num(options.page),
181
+ }, creds);
182
+ }
183
+
184
+ // ─── users ───────────────────────────────────────────────────
185
+ export function userList(creds, options = {}) {
186
+ return webApiCall("users.list", {
187
+ limit: num(options.limit) ?? 200,
188
+ cursor: options.cursor,
189
+ }, creds);
190
+ }
191
+
192
+ export function userInfo(creds, user) {
193
+ return webApiCall("users.info", { user }, creds);
194
+ }
195
+
196
+ // ─── reactions ───────────────────────────────────────────────
197
+ export function reactionAdd(creds, channel, ts, name) {
198
+ return webApiCall("reactions.add", { channel, timestamp: ts, name }, creds);
199
+ }
200
+
201
+ export function reactionRemove(creds, channel, ts, name) {
202
+ return webApiCall("reactions.remove", { channel, timestamp: ts, name }, creds);
203
+ }
204
+
205
+ // ─── files ───────────────────────────────────────────────────
206
+ export function fileList(creds, options = {}) {
207
+ return webApiCall("files.list", {
208
+ channel: options.channel,
209
+ user: options.user,
210
+ count: num(options.limit) ?? 50,
211
+ page: num(options.page),
212
+ }, creds);
213
+ }
214
+
215
+ export function fileInfo(creds, file) {
216
+ return webApiCall("files.info", { file }, creds);
217
+ }
218
+
219
+ // ─── pins ────────────────────────────────────────────────────
220
+ export function pinList(creds, channel) {
221
+ return webApiCall("pins.list", { channel }, creds);
222
+ }
223
+
224
+ export function pinAdd(creds, channel, ts) {
225
+ return webApiCall("pins.add", { channel, timestamp: ts }, creds);
226
+ }
227
+
228
+ export function pinRemove(creds, channel, ts) {
229
+ return webApiCall("pins.remove", { channel, timestamp: ts }, creds);
230
+ }
231
+
232
+ // ─── canvases ────────────────────────────────────────────────
233
+ export function canvasList(creds, options = {}) {
234
+ return webApiCall("files.list", {
235
+ types: "canvas",
236
+ count: num(options.limit) ?? 50,
237
+ channel: options.channel,
238
+ }, creds);
239
+ }
240
+
241
+ export function canvasGet(creds, canvas) {
242
+ // Canvases are files; files.info returns the canvas file record, including the
243
+ // url fields Slack provides for fetching the content.
244
+ return webApiCall("files.info", { file: canvas }, creds);
245
+ }
246
+
247
+ export { SlackApiError };
package/lib/auth.js ADDED
@@ -0,0 +1,255 @@
1
+ // Credential storage and resolution for native-session (xoxc + xoxd) auth.
2
+ //
3
+ // Resolution order when a command needs credentials:
4
+ // 1. explicit options.token + options.cookie
5
+ // 2. env SLACK_TOKEN + SLACK_COOKIE
6
+ // 3. persisted ~/.config/slack-cli/credentials.json (team selected by
7
+ // options.team, env SLACK_TEAM, persisted default, or first workspace)
8
+
9
+ import { readFile, writeFile, mkdir, unlink } from "node:fs/promises";
10
+ import { dirname } from "node:path";
11
+ import { CLI_NAME, CONFIG_ENV_KEYS, CREDENTIALS_FILE } from "./config.js";
12
+ import { extractCredentials } from "./extract.js";
13
+ import { webApiCall } from "./api.js";
14
+
15
+ const CREDENTIALS_DIR = dirname(CREDENTIALS_FILE);
16
+
17
+ function nowIso() {
18
+ return new Date().toISOString();
19
+ }
20
+
21
+ async function readStore() {
22
+ try {
23
+ return JSON.parse(await readFile(CREDENTIALS_FILE, "utf8"));
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ async function writeStore(store) {
30
+ await mkdir(CREDENTIALS_DIR, { recursive: true });
31
+ await writeFile(CREDENTIALS_FILE, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 });
32
+ }
33
+
34
+ export async function clearCredentials() {
35
+ try {
36
+ await unlink(CREDENTIALS_FILE);
37
+ } catch (err) {
38
+ if (err?.code !== "ENOENT") throw err;
39
+ }
40
+ }
41
+
42
+ function hostFrom(workspace) {
43
+ return workspace?.host || (workspace?.url ? workspace.url.replace(/^https?:\/\//, "").split("/")[0] : null);
44
+ }
45
+
46
+ function matchWorkspace(workspaces, selector) {
47
+ if (!selector || !Array.isArray(workspaces)) return null;
48
+ const needle = String(selector).toLowerCase().replace(/^https?:\/\//, "").replace(/\/+$/, "");
49
+ return (
50
+ workspaces.find((w) => w.id && String(w.id).toLowerCase() === needle) ||
51
+ workspaces.find((w) => w.name && w.name.toLowerCase() === needle) ||
52
+ workspaces.find((w) => hostFrom(w) && hostFrom(w).toLowerCase() === needle) ||
53
+ workspaces.find((w) => hostFrom(w) && hostFrom(w).toLowerCase().startsWith(`${needle}.`)) ||
54
+ null
55
+ );
56
+ }
57
+
58
+ function selectWorkspace(store, selector) {
59
+ const workspaces = store?.workspaces || [];
60
+ if (workspaces.length === 0) return null;
61
+ return (
62
+ matchWorkspace(workspaces, selector) ||
63
+ matchWorkspace(workspaces, store?.default_team) ||
64
+ workspaces[0]
65
+ );
66
+ }
67
+
68
+ function hostFromUrl(url) {
69
+ if (!url) return null;
70
+ try {
71
+ return new URL(url).host;
72
+ } catch {
73
+ return url.replace(/^https?:\/\//, "").split("/")[0] || null;
74
+ }
75
+ }
76
+
77
+ // Turn raw xoxc tokens into verified workspace records by calling auth.test for
78
+ // each with the shared cookie. Invalid or stale tokens are dropped; duplicates
79
+ // for the same workspace collapse to one.
80
+ async function enrichTokens(tokens, cookie, cookieDs) {
81
+ const byTeam = new Map();
82
+ const errors = [];
83
+ for (const token of tokens) {
84
+ try {
85
+ const who = await webApiCall("auth.test", {}, { token, cookie, cookieDs, host: null });
86
+ const url = who.url ? who.url.replace(/\/+$/, "") : null;
87
+ byTeam.set(who.team_id || url || token, {
88
+ id: who.team_id || null,
89
+ name: who.team || null,
90
+ url,
91
+ host: hostFromUrl(who.url),
92
+ user_id: who.user_id || null,
93
+ token,
94
+ });
95
+ } catch (err) {
96
+ errors.push(err.message);
97
+ }
98
+ }
99
+ const workspaces = [...byTeam.values()];
100
+ if (workspaces.length === 0) {
101
+ throw new Error(
102
+ `Found ${tokens.length} token(s) but none verified against Slack${
103
+ errors.length ? ` (last error: ${errors[errors.length - 1]})` : ""
104
+ }. The session may have expired; reopen Slack or use \`slack-cli auth import\`.`,
105
+ );
106
+ }
107
+ return workspaces;
108
+ }
109
+
110
+ // Resolve { token, cookie, cookieDs, host, team } for an API call.
111
+ export async function getCredentials(options = {}) {
112
+ if (options.token && options.cookie) {
113
+ return {
114
+ token: options.token,
115
+ cookie: options.cookie,
116
+ cookieDs: options.cookieDs || null,
117
+ host: options.host || null,
118
+ team: null,
119
+ };
120
+ }
121
+
122
+ const envToken = process.env[CONFIG_ENV_KEYS.token];
123
+ const envCookie = process.env[CONFIG_ENV_KEYS.cookie];
124
+ if (envToken && envCookie) {
125
+ return {
126
+ token: envToken,
127
+ cookie: envCookie,
128
+ cookieDs: process.env[CONFIG_ENV_KEYS.cookieDs] || null,
129
+ host: options.host || null,
130
+ team: null,
131
+ };
132
+ }
133
+
134
+ const store = await readStore();
135
+ if (!store?.cookie || !store?.workspaces?.length) {
136
+ throw new Error(`Not authenticated. Run: ${CLI_NAME} auth login`);
137
+ }
138
+ const selector = options.team || process.env[CONFIG_ENV_KEYS.team];
139
+ const workspace = selectWorkspace(store, selector);
140
+ if (!workspace?.token) {
141
+ throw new Error(`No matching Slack workspace. Run: ${CLI_NAME} auth status`);
142
+ }
143
+ return {
144
+ token: workspace.token,
145
+ cookie: store.cookie,
146
+ cookieDs: store.cookie_ds || null,
147
+ host: hostFrom(workspace),
148
+ team: workspace,
149
+ };
150
+ }
151
+
152
+ export async function getAuthStatus() {
153
+ const envToken = process.env[CONFIG_ENV_KEYS.token];
154
+ const envCookie = process.env[CONFIG_ENV_KEYS.cookie];
155
+ if (envToken && envCookie) {
156
+ return { authenticated: true, source: "env", workspaces: [] };
157
+ }
158
+ const store = await readStore();
159
+ if (!store?.cookie || !store?.workspaces?.length) {
160
+ return { authenticated: false, source: "missing", workspaces: [] };
161
+ }
162
+ return {
163
+ authenticated: true,
164
+ source: "persisted",
165
+ default_team: store.default_team || null,
166
+ stored_at: store.stored_at || null,
167
+ workspaces: store.workspaces.map((w) => ({
168
+ id: w.id,
169
+ name: w.name,
170
+ host: hostFrom(w),
171
+ user_id: w.user_id,
172
+ })),
173
+ };
174
+ }
175
+
176
+ // Extract from the desktop app, verify each token, and persist.
177
+ export async function login(options = {}) {
178
+ const { tokens, cookie, cookieDs } = extractCredentials();
179
+ const workspaces = await enrichTokens(tokens, cookie, cookieDs);
180
+ const store = {
181
+ auth_type: "session",
182
+ cookie,
183
+ cookie_ds: cookieDs || null,
184
+ workspaces,
185
+ default_team: matchWorkspace(workspaces, options.team)?.id || workspaces[0]?.id || null,
186
+ stored_at: nowIso(),
187
+ };
188
+ await writeStore(store);
189
+
190
+ const active = selectWorkspace(store, options.team);
191
+ return {
192
+ persisted: true,
193
+ workspace_count: workspaces.length,
194
+ active: { team: active.name, team_id: active.id, user_id: active.user_id, url: active.url },
195
+ workspaces: workspaces.map((w) => ({ team: w.name, id: w.id, host: w.host })),
196
+ };
197
+ }
198
+
199
+ // Persist credentials supplied by hand (token + cookie), bypassing extraction.
200
+ export async function importCredentials({ token, cookie, cookieDs, host, team } = {}) {
201
+ if (!token || !token.startsWith("xoxc-")) {
202
+ throw new Error("`--token` must be an xoxc- web token.");
203
+ }
204
+ if (!cookie) {
205
+ throw new Error("`--cookie` is required (the xoxd- `d` cookie value).");
206
+ }
207
+ const cookieValue = cookie.startsWith("xoxd-") ? cookie : `xoxd-${cookie}`;
208
+ const cookieDsValue = cookieDs || null;
209
+ const resolvedHost = host || null;
210
+ const verify = await webApiCall("auth.test", {}, { token, cookie: cookieValue, cookieDs: cookieDsValue, host: resolvedHost });
211
+
212
+ const workspace = {
213
+ id: verify.team_id || null,
214
+ name: verify.team || team || null,
215
+ url: verify.url ? verify.url.replace(/\/+$/, "") : null,
216
+ host: verify.url ? new URL(verify.url).host : resolvedHost,
217
+ user_id: verify.user_id || null,
218
+ token,
219
+ };
220
+ await writeStore({
221
+ auth_type: "session",
222
+ cookie: cookieValue,
223
+ cookie_ds: cookieDsValue,
224
+ workspaces: [workspace],
225
+ default_team: workspace.id,
226
+ stored_at: nowIso(),
227
+ });
228
+ return { persisted: true, verified: Boolean(verify.ok), active: { team: workspace.name, user: verify.user } };
229
+ }
230
+
231
+ // Parse token + cookie out of a cURL command copied from browser devtools.
232
+ export function parseCurl(curl) {
233
+ if (typeof curl !== "string" || !curl.trim()) {
234
+ throw new Error("Provide the cURL string copied from devtools.");
235
+ }
236
+ const tokenMatch = curl.match(/xoxc-[A-Za-z0-9-]+/);
237
+ const cookieMatch = curl.match(/xoxd-[A-Za-z0-9%._-]+/);
238
+ const dsMatch = curl.match(/d-s=([^;'"\s]+)/);
239
+ const hostMatch = curl.match(/https?:\/\/([a-z0-9-]+\.slack\.com)/i);
240
+ if (!tokenMatch) throw new Error("No xoxc- token found in the cURL command.");
241
+ if (!cookieMatch) throw new Error("No xoxd- cookie found in the cURL command.");
242
+ // Keep cookie values verbatim (percent-encoded as they travel on the wire);
243
+ // decoding would corrupt the trailing `%3D` and Slack would reject it.
244
+ return {
245
+ token: tokenMatch[0],
246
+ cookie: cookieMatch[0],
247
+ cookieDs: dsMatch ? dsMatch[1] : null,
248
+ host: hostMatch ? hostMatch[1] : null,
249
+ };
250
+ }
251
+
252
+ export async function logout() {
253
+ await clearCredentials();
254
+ return { loggedOut: true };
255
+ }
package/lib/config.js ADDED
@@ -0,0 +1,57 @@
1
+ import { createRequire } from "node:module";
2
+ import { homedir, platform } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ const require = createRequire(import.meta.url);
6
+ const { version: PACKAGE_JSON_VERSION } = require("../package.json");
7
+
8
+ export const TOOL_NAME = "slack";
9
+ export const PACKAGE_NAME = "@ruminaider/slack-cli";
10
+ export const CLI_NAME = "slack-cli";
11
+ export const PACKAGE_VERSION = PACKAGE_JSON_VERSION;
12
+
13
+ export const CONFIG_DIR = join(homedir(), ".config", CLI_NAME);
14
+ export const CREDENTIALS_FILE = join(CONFIG_DIR, "credentials.json");
15
+
16
+ // Environment overrides (CI / headless use).
17
+ export const CONFIG_ENV_KEYS = Object.freeze({
18
+ token: "SLACK_TOKEN", // xoxc- web token
19
+ cookie: "SLACK_COOKIE", // xoxd- d cookie value
20
+ cookieDs: "SLACK_COOKIE_DS", // optional d-s companion cookie (Enterprise Grid / SSO)
21
+ team: "SLACK_TEAM", // default team name, id, or host
22
+ });
23
+
24
+ // Slack desktop app data locations by platform.
25
+ function slackAppDir() {
26
+ const home = homedir();
27
+ switch (platform()) {
28
+ case "darwin":
29
+ return join(home, "Library", "Application Support", "Slack");
30
+ case "linux":
31
+ return join(home, ".config", "Slack");
32
+ case "win32":
33
+ return join(process.env.APPDATA || join(home, "AppData", "Roaming"), "Slack");
34
+ default:
35
+ return null;
36
+ }
37
+ }
38
+
39
+ export const SLACK_APP_DIR = slackAppDir();
40
+ export const SLACK_LEVELDB_DIR = SLACK_APP_DIR
41
+ ? join(SLACK_APP_DIR, "Local Storage", "leveldb")
42
+ : null;
43
+
44
+ // Slack has shipped the Cookies file at the app root and, on some builds, under
45
+ // Network/ or Default/. Resolution tries each in order at extraction time.
46
+ export const SLACK_COOKIE_CANDIDATES = SLACK_APP_DIR
47
+ ? [
48
+ join(SLACK_APP_DIR, "Cookies"),
49
+ join(SLACK_APP_DIR, "Network", "Cookies"),
50
+ join(SLACK_APP_DIR, "Default", "Cookies"),
51
+ ]
52
+ : [];
53
+
54
+ // macOS Keychain accounts that have held the Slack cookie-encryption key.
55
+ export const MACOS_KEYCHAIN_SERVICES = ["Slack Safe Storage", "Slack Key", "Slack App Store Key"];
56
+
57
+ export const DEFAULT_API_HOST = "slack.com";
package/lib/extract.js ADDED
@@ -0,0 +1,218 @@
1
+ // Native-session credential extraction from the local Slack desktop app.
2
+ //
3
+ // Two pieces are needed to call the Slack web API as the logged-in user:
4
+ // - the xoxc web token(s), one per workspace, stored in the app's LevelDB
5
+ // localStorage under the `localConfig_v2` key
6
+ // - the xoxd `d` cookie, stored encrypted in the app's Chromium SQLite cookie
7
+ // store and decrypted with a per-platform key
8
+ //
9
+ // The LevelDB data blocks are Snappy-compressed, so the surrounding JSON is not
10
+ // readable by a plain scan. The xoxc tokens, however, are unique random strings
11
+ // that Snappy always stores as verbatim literals, so we regex them straight out
12
+ // of the raw bytes and enrich each one over the network with auth.test rather
13
+ // than parsing the compressed structure. Everything uses Node built-ins only
14
+ // (node:sqlite, node:crypto, node:child_process, node:fs); no npm dependencies.
15
+
16
+ import { readFileSync, readdirSync, existsSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ import { platform } from "node:os";
19
+ import { execFileSync } from "node:child_process";
20
+ import { pbkdf2Sync, createDecipheriv, createHash } from "node:crypto";
21
+ import { createRequire } from "node:module";
22
+ import {
23
+ SLACK_LEVELDB_DIR,
24
+ SLACK_COOKIE_CANDIDATES,
25
+ MACOS_KEYCHAIN_SERVICES,
26
+ } from "./config.js";
27
+
28
+ // ─── xoxc tokens from LevelDB ───────────────────────────────
29
+
30
+ // xoxc tokens look like `xoxc-<digits>-<digits>-<digits>-<hex>`; the trailing
31
+ // segment is long, so a 40-char minimum avoids matching a truncated fragment
32
+ // left at a Snappy block boundary.
33
+ const TOKEN_RE = /xoxc-[A-Za-z0-9-]{40,}/g;
34
+
35
+ // Regex every xoxc token out of the raw LevelDB files and dedupe. Tokens are
36
+ // unique literals that survive Snappy compression intact; we do not try to
37
+ // parse the surrounding JSON. Any valid token wins after auth.test enrichment,
38
+ // so order here does not matter.
39
+ export function extractTokens() {
40
+ if (!SLACK_LEVELDB_DIR || !existsSync(SLACK_LEVELDB_DIR)) {
41
+ throw new Error(
42
+ "Slack desktop app data not found. Open the Slack app and sign in, or import credentials manually with `slack-cli auth import`.",
43
+ );
44
+ }
45
+
46
+ const files = readdirSync(SLACK_LEVELDB_DIR)
47
+ .filter((name) => name.endsWith(".ldb") || name.endsWith(".log"))
48
+ .map((name) => join(SLACK_LEVELDB_DIR, name));
49
+
50
+ const tokens = new Set();
51
+ for (const file of files) {
52
+ let text;
53
+ try {
54
+ text = readFileSync(file).toString("latin1");
55
+ } catch {
56
+ continue;
57
+ }
58
+ for (const match of text.matchAll(TOKEN_RE)) tokens.add(match[0]);
59
+ }
60
+
61
+ const list = [...tokens];
62
+ if (list.length === 0) {
63
+ throw new Error(
64
+ "No Slack workspace tokens found in the desktop app. Sign in to Slack, or import credentials manually with `slack-cli auth import`.",
65
+ );
66
+ }
67
+ return list;
68
+ }
69
+
70
+ // ─── xoxd cookie from the Chromium SQLite store ─────────────
71
+
72
+ function resolveCookieDb() {
73
+ for (const path of SLACK_COOKIE_CANDIDATES) {
74
+ if (existsSync(path)) return path;
75
+ }
76
+ throw new Error("Slack cookie store not found. Sign in to the Slack desktop app first.");
77
+ }
78
+
79
+ // node:sqlite is a built-in but was flag-gated before Node 24, so it is loaded
80
+ // lazily here: only auto-extraction touches the cookie DB, leaving the env-var
81
+ // and `auth import` paths usable on older Node.
82
+ function loadSqlite() {
83
+ const require = createRequire(import.meta.url);
84
+ try {
85
+ return require("node:sqlite");
86
+ } catch (err) {
87
+ throw new Error(
88
+ `Reading the Slack cookie store needs Node's built-in node:sqlite (Node 24+, or Node 22–23 with --experimental-sqlite). Upgrade Node, or use \`slack-cli auth import\`. (${err.message})`,
89
+ );
90
+ }
91
+ }
92
+
93
+ // Read the encrypted `d` cookie (required) and its `d-s` companion (optional,
94
+ // present on Enterprise Grid and some SSO setups) from the cookie store.
95
+ function readEncryptedCookies() {
96
+ const { DatabaseSync } = loadSqlite();
97
+ const dbPath = resolveCookieDb();
98
+ const db = new DatabaseSync(dbPath, { readOnly: true });
99
+ try {
100
+ const pick = (name) =>
101
+ db
102
+ .prepare(
103
+ "SELECT encrypted_value, host_key FROM cookies WHERE name = ? ORDER BY length(encrypted_value) DESC LIMIT 1",
104
+ )
105
+ .get(name);
106
+ const dRow = pick("d");
107
+ if (!dRow?.encrypted_value) {
108
+ throw new Error("No `d` cookie present in the Slack cookie store.");
109
+ }
110
+ // node:sqlite returns BLOBs as Uint8Array.
111
+ const toEntry = (row) =>
112
+ row?.encrypted_value
113
+ ? { encrypted: Buffer.from(row.encrypted_value), hostKey: row.host_key || ".slack.com" }
114
+ : null;
115
+ return { d: toEntry(dRow), ds: toEntry(pick("d-s")) };
116
+ } finally {
117
+ db.close();
118
+ }
119
+ }
120
+
121
+ // Chromium prepends a 32-byte SHA-256(host_key) to the plaintext for newer
122
+ // cookie store versions. Strip it when present so we return the raw value.
123
+ function stripHostPrefix(plaintext, hostKey) {
124
+ if (plaintext.length <= 32) return plaintext;
125
+ const expected = createHash("sha256").update(hostKey).digest();
126
+ if (plaintext.subarray(0, 32).equals(expected)) {
127
+ return plaintext.subarray(32);
128
+ }
129
+ return plaintext;
130
+ }
131
+
132
+ function macosDecryptionKey() {
133
+ let lastError;
134
+ for (const service of MACOS_KEYCHAIN_SERVICES) {
135
+ try {
136
+ const secret = execFileSync(
137
+ "security",
138
+ ["find-generic-password", "-w", "-s", service],
139
+ { encoding: "utf8" },
140
+ ).trim();
141
+ if (secret) {
142
+ return pbkdf2Sync(secret, "saltysalt", 1003, 16, "sha1");
143
+ }
144
+ } catch (err) {
145
+ lastError = err;
146
+ }
147
+ }
148
+ throw new Error(
149
+ `Could not read the Slack cookie-encryption key from the macOS Keychain${
150
+ lastError ? ` (${lastError.message})` : ""
151
+ }. Approve the Keychain prompt, or import credentials manually with \`slack-cli auth import\`.`,
152
+ );
153
+ }
154
+
155
+ function linuxDecryptionKey() {
156
+ // Slack on Linux commonly uses Chromium's hardcoded v10 fallback key.
157
+ return pbkdf2Sync("peanuts", "saltysalt", 1, 16, "sha1");
158
+ }
159
+
160
+ // requireXoxd validates the decrypted value looks like a `d` cookie (`xoxd-`).
161
+ // The `d-s` companion is an opaque session value, so it passes requireXoxd:false.
162
+ function decryptCookieValue(encrypted, hostKey, requireXoxd = true) {
163
+ const prefix = encrypted.subarray(0, 3).toString("utf8");
164
+ if (prefix !== "v10" && prefix !== "v11") {
165
+ // Some stores keep the value in plaintext (older Slack builds).
166
+ const asText = encrypted.toString("utf8");
167
+ if (!requireXoxd || asText.startsWith("xoxd-")) return asText;
168
+ throw new Error(`Unsupported cookie encryption version: ${JSON.stringify(prefix)}.`);
169
+ }
170
+
171
+ const os = platform();
172
+ let key;
173
+ if (os === "darwin") key = macosDecryptionKey();
174
+ else if (os === "linux") key = linuxDecryptionKey();
175
+ else throw new Error(`Cookie decryption is not implemented for platform ${os}. Use \`slack-cli auth import\`.`);
176
+
177
+ const iv = Buffer.alloc(16, " ");
178
+ const decipher = createDecipheriv("aes-128-cbc", key, iv);
179
+ decipher.setAutoPadding(true);
180
+ const body = encrypted.subarray(3);
181
+ let plaintext;
182
+ try {
183
+ plaintext = Buffer.concat([decipher.update(body), decipher.final()]);
184
+ } catch (err) {
185
+ throw new Error(`Cookie decryption failed: ${err.message}. Try \`slack-cli auth import\`.`);
186
+ }
187
+ const value = stripHostPrefix(plaintext, hostKey).toString("utf8").replace(/\u0000+$/, "");
188
+ if (requireXoxd && !value.startsWith("xoxd-")) {
189
+ throw new Error("Decrypted cookie does not look like a Slack `d` cookie.");
190
+ }
191
+ return value;
192
+ }
193
+
194
+ // Returns { d, ds } where ds is null when the workspace has no d-s cookie.
195
+ export function extractCookies() {
196
+ const { d, ds } = readEncryptedCookies();
197
+ return {
198
+ d: decryptCookieValue(d.encrypted, d.hostKey, true),
199
+ ds: ds ? decryptCookieValue(ds.encrypted, ds.hostKey, false) : null,
200
+ };
201
+ }
202
+
203
+ // ─── combined ────────────────────────────────────────────────
204
+
205
+ // Extract every workspace token plus the shared d cookie (and its optional d-s
206
+ // companion). The cookies are the same across all workspaces, so they are read
207
+ // once. Tokens are enriched into full workspace records by the caller via
208
+ // auth.test.
209
+ export function extractCredentials() {
210
+ if (!SLACK_LEVELDB_DIR) {
211
+ throw new Error(
212
+ `Automatic extraction is not supported on platform ${platform()}. Use \`slack-cli auth import\`.`,
213
+ );
214
+ }
215
+ const tokens = extractTokens();
216
+ const { d, ds } = extractCookies();
217
+ return { tokens, cookie: d, cookieDs: ds };
218
+ }
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@ruminaider/slack-cli",
3
+ "version": "0.1.0",
4
+ "description": "Slack CLI that authenticates as you using your existing Slack desktop session. Read channels, send messages, search, and manage reactions, files, pins, and canvases from the terminal. No app, no OAuth, no admin approval.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/ruminaider/agent-clis.git",
10
+ "directory": "slack/cli"
11
+ },
12
+ "keywords": [
13
+ "slack",
14
+ "cli",
15
+ "mcp",
16
+ "agent",
17
+ "pi-package"
18
+ ],
19
+ "bin": {
20
+ "slack-cli": "bin/slack.js"
21
+ },
22
+ "files": [
23
+ "bin/",
24
+ "lib/"
25
+ ],
26
+ "engines": {
27
+ "node": ">=22"
28
+ }
29
+ }