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/ts/cli.ts ADDED
@@ -0,0 +1,1010 @@
1
+ #!/usr/bin/env bun
2
+ // Slack CLI entry — mirrors the Rust impl in src/main.rs.
3
+
4
+ import { parseArgs } from "node:util";
5
+ import { createHash } from "node:crypto";
6
+ import { existsSync, readFileSync, mkdirSync, writeFileSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { join } from "node:path";
9
+
10
+ import { addProfile, listProfiles, removeProfile, resolveCookie, resolveToken, setCookie, useProfile } from "./profiles.ts";
11
+ import { extractSessions } from "./slack-app.ts";
12
+
13
+ import {
14
+ authTest,
15
+ authTestSession,
16
+ conversationInfoSession,
17
+ createDraft,
18
+ deleteDraft,
19
+ updateDraft,
20
+ editMessage,
21
+ history,
22
+ listConversations,
23
+ listDrafts,
24
+ openDm,
25
+ parseSlackPermalink,
26
+ replies,
27
+ resolveChannel,
28
+ search,
29
+ searchAll,
30
+ send as slackSend,
31
+ uploadFile,
32
+ userInfoPair,
33
+ userName,
34
+ getPath,
35
+ type Json,
36
+ } from "./slack.ts";
37
+ import { dayLabel, formatHm, formatYmdHm, resolveDateMarkup, resolveMentions } from "./format.ts";
38
+
39
+ function loadDotenv(path: string): void {
40
+ if (!existsSync(path)) return;
41
+ const text = readFileSync(path, "utf8");
42
+ for (const rawLine of text.split("\n")) {
43
+ const line = rawLine.trim();
44
+ if (!line || line.startsWith("#")) continue;
45
+ const eq = line.indexOf("=");
46
+ if (eq === -1) continue;
47
+ const key = line.slice(0, eq).trim();
48
+ let val = line.slice(eq + 1).trim();
49
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
50
+ val = val.slice(1, -1);
51
+ }
52
+ if (!(key in process.env)) process.env[key] = val;
53
+ }
54
+ }
55
+
56
+ function ensureSlackCliDir(dir: string): void {
57
+ mkdirSync(dir, { recursive: true });
58
+ const gi = join(dir, ".gitignore");
59
+ if (!existsSync(gi)) writeFileSync(gi, "*\n");
60
+ }
61
+
62
+ function loadDotenvFiles(): void {
63
+ // Global home config
64
+ loadDotenv(join(homedir(), ".slack-cli", ".env.local"));
65
+ loadDotenv(join(homedir(), ".config/slack-cli", ".env"));
66
+ // Local project overrides (highest priority — loaded last so they win)
67
+ loadDotenv(join(process.cwd(), ".slack-cli", ".env.local"));
68
+ loadDotenv(join(process.cwd(), ".env.local"));
69
+ loadDotenv(join(process.cwd(), ".env"));
70
+ }
71
+
72
+ function sha256Hex(input: string): string {
73
+ return createHash("sha256").update(input).digest("hex");
74
+ }
75
+
76
+ function asRecord(v: Json | undefined): Record<string, Json> {
77
+ return v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, Json>) : {};
78
+ }
79
+
80
+ function asArray(v: Json | undefined): Json[] {
81
+ return Array.isArray(v) ? v : [];
82
+ }
83
+
84
+ function tsNum(m: Record<string, Json>): number {
85
+ return Number(m.ts ?? 0);
86
+ }
87
+
88
+ async function displayUser(
89
+ token: string,
90
+ m: Record<string, Json>,
91
+ cache: Map<string, string>,
92
+ ): Promise<string> {
93
+ const uid = m.user;
94
+ if (typeof uid === "string") {
95
+ if (!cache.has(uid)) cache.set(uid, await userName(token, uid));
96
+ return cache.get(uid) ?? uid;
97
+ }
98
+ return typeof m.username === "string" ? m.username : "bot";
99
+ }
100
+
101
+ function formatYmdHmsUtc(epochSec: number): string {
102
+ const d = new Date(epochSec * 1000);
103
+ const y = d.getUTCFullYear();
104
+ const mo = String(d.getUTCMonth() + 1).padStart(2, "0");
105
+ const da = String(d.getUTCDate()).padStart(2, "0");
106
+ const h = String(d.getUTCHours()).padStart(2, "0");
107
+ const mi = String(d.getUTCMinutes()).padStart(2, "0");
108
+ const s = String(d.getUTCSeconds()).padStart(2, "0");
109
+ return `${y}-${mo}-${da} ${h}:${mi}:${s}`;
110
+ }
111
+
112
+ // Format one message line: `[YYYY-MM-DD HH:MM:SS] <real|@handle> text` (UTC)
113
+ async function formatMsgLine(
114
+ token: string,
115
+ m: Record<string, Json>,
116
+ cache: Map<string, string>,
117
+ ): Promise<string> {
118
+ const ts = tsNum(m);
119
+ const stamp = formatYmdHmsUtc(ts);
120
+ let real = "?";
121
+ let handle = "?";
122
+ if (typeof m.user === "string") {
123
+ const uid = m.user;
124
+ const realKey = uid;
125
+ const handleKey = "@" + uid;
126
+ if (!cache.has(realKey) || !cache.has(handleKey)) {
127
+ const [d, h] = await userInfoPair(token, uid);
128
+ cache.set(realKey, d);
129
+ cache.set(handleKey, h);
130
+ }
131
+ real = cache.get(realKey) ?? uid;
132
+ handle = cache.get(handleKey) ?? uid;
133
+ } else if (typeof m.username === "string") {
134
+ real = m.username;
135
+ handle = m.username;
136
+ }
137
+ const raw = typeof m.text === "string" ? m.text : "";
138
+ const resolved = resolveDateMarkup(await resolveMentions(token, raw, cache));
139
+ const oneline = resolved.split("\n").join(" ↵ ");
140
+ return `[${stamp}] <${real}|@${handle}> ${oneline}`;
141
+ }
142
+
143
+ // --- msgs <target> — channel/DM history with timestamps ---
144
+ async function cmdMsgsTarget(token: string, target: string, limit: number): Promise<void> {
145
+ const channelId = await resolveChannel(token, target);
146
+ const hist = (await history(token, channelId, limit)) as Record<string, Json>;
147
+ const msgs = asArray(hist.messages).map(asRecord);
148
+ const cache = new Map<string, string>();
149
+ for (const m of msgs.reverse()) {
150
+ console.log(await formatMsgLine(token, m, cache));
151
+ }
152
+ }
153
+
154
+ // --- thread ---
155
+ async function cmdThread(token: string, target: string, ts: string, limit: number): Promise<void> {
156
+ const channelId = await resolveChannel(token, target);
157
+ const resp = (await replies(token, channelId, ts, limit)) as Record<string, Json>;
158
+ const msgs = asArray(resp.messages).map(asRecord);
159
+ const cache = new Map<string, string>();
160
+ for (const m of msgs) {
161
+ console.log(await formatMsgLine(token, m, cache));
162
+ }
163
+ }
164
+
165
+ // --- msgs (no target) ---
166
+ async function cmdMsgs(token: string): Promise<void> {
167
+ const resp = (await listConversations(token)) as Record<string, Json>;
168
+ const channels = asArray(resp.channels)
169
+ .map(asRecord)
170
+ .filter((c) => c.is_member === true)
171
+ .sort((a, b) => Number(b.updated ?? 0) - Number(a.updated ?? 0))
172
+ .slice(0, 10);
173
+
174
+ const cache = new Map<string, string>();
175
+ for (const ch of channels) {
176
+ const id = String(ch.id ?? "");
177
+ const name = typeof ch.name === "string" ? ch.name : typeof ch.user === "string" ? ch.user : id;
178
+ const hist = (await history(token, id, 5)) as Record<string, Json>;
179
+ const msgs = asArray(hist.messages)
180
+ .map(asRecord)
181
+ .filter((m) => m.subtype === undefined || m.subtype === null)
182
+ .filter((m) => {
183
+ const t = typeof m.text === "string" ? m.text : "";
184
+ return t.length > 0 && !t.startsWith("<@");
185
+ })
186
+ .slice(0, 3);
187
+ if (msgs.length === 0) continue;
188
+ console.log(`── #${name} ─────────────────────────────────`);
189
+ for (const m of msgs) {
190
+ const who = await displayUser(token, m, cache);
191
+ const raw = (typeof m.text === "string" ? m.text : "").split("\n")[0] ?? "";
192
+ const text = await resolveMentions(token, raw, cache);
193
+ console.log(` ${who}: ${text}`);
194
+ }
195
+ }
196
+ }
197
+
198
+ // --- news ---
199
+ async function cmdNews(token: string, limit: number): Promise<void> {
200
+ const resp = (await search(token, "to:me")) as Record<string, Json>;
201
+ const matches = asArray(getPath(resp, ["messages", "matches"])).map(asRecord);
202
+ const cache = new Map<string, string>();
203
+ let lastDay = "";
204
+ for (const m of matches.slice(0, limit)) {
205
+ const ts = tsNum(m);
206
+ const label = dayLabel(ts);
207
+ if (label !== lastDay) {
208
+ if (lastDay !== "") console.log("");
209
+ console.log(` ${label}`);
210
+ console.log(" ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄");
211
+ lastDay = label;
212
+ }
213
+ const ch = asRecord(m.channel);
214
+ const isIm = ch.is_im === true;
215
+ const rawName = typeof ch.name === "string" ? ch.name : "dm";
216
+ let chLabel: string;
217
+ if (isIm && rawName.startsWith("U")) {
218
+ if (!cache.has(rawName)) cache.set(rawName, await userName(token, rawName));
219
+ chLabel = `@${cache.get(rawName) ?? rawName}`;
220
+ } else if (isIm) {
221
+ chLabel = `@${rawName}`;
222
+ } else {
223
+ chLabel = `#${rawName}`;
224
+ }
225
+ const display = await displayUser(token, m, cache);
226
+ const raw = typeof m.text === "string" ? m.text : "";
227
+ const resolved = resolveDateMarkup(await resolveMentions(token, raw, cache));
228
+ const firstLine = (resolved.split("\n")[0] ?? "").slice(0, 80);
229
+ const icon = isIm ? "💬" : "🔔";
230
+ console.log(` ${icon} ${chLabel} ${formatHm(ts)}`);
231
+ console.log(` ${display}: ${firstLine}`);
232
+ }
233
+ }
234
+
235
+ // --- channels ---
236
+ async function cmdChannels(token: string, limit: number, filter?: string, all?: boolean): Promise<void> {
237
+ const resp = (await listConversations(token)) as Record<string, Json>;
238
+ let channels = asArray(resp.channels)
239
+ .map(asRecord)
240
+ .filter((c) => all || c.is_member === true)
241
+ .filter((c) => {
242
+ if (!filter) return true;
243
+ const n = typeof c.name === "string" ? c.name : "";
244
+ return n.toLowerCase().includes(filter.toLowerCase());
245
+ })
246
+ .sort((a, b) => Number(b.updated ?? 0) - Number(a.updated ?? 0))
247
+ .slice(0, limit);
248
+
249
+ for (const ch of channels) {
250
+ const id = String(ch.id ?? "");
251
+ const name = typeof ch.name === "string" ? ch.name : typeof ch.user === "string" ? ch.user : id;
252
+ const isIm = ch.is_im === true;
253
+ const isMpim = ch.is_mpim === true;
254
+ const prefix = isIm || isMpim ? "@" : "#";
255
+ const memberMark = ch.is_member === true ? "" : " (not joined)";
256
+ console.log(`${prefix}${name}\t${id}${memberMark}`);
257
+ }
258
+ }
259
+
260
+ // --- search ---
261
+ async function cmdSearch(token: string, query: string, count: number): Promise<void> {
262
+ const resp = await searchAll(token, query, count);
263
+ console.log(JSON.stringify(resp, null, 2));
264
+ }
265
+
266
+ // --- dump ---
267
+ async function cmdDump(
268
+ token: string,
269
+ days: number,
270
+ limit: number,
271
+ filter?: string,
272
+ ): Promise<void> {
273
+ const cutoff = Math.floor(Date.now() / 1000) - days * 86400;
274
+ const resp = (await listConversations(token)) as Record<string, Json>;
275
+ const channels = asArray(resp.channels)
276
+ .map(asRecord)
277
+ .filter((c) => c.is_member === true && c.is_im !== true && c.is_mpim !== true)
278
+ .filter((c) => {
279
+ if (!filter) return true;
280
+ const n = typeof c.name === "string" ? c.name : "";
281
+ return n.toLowerCase().includes(filter.toLowerCase());
282
+ })
283
+ .sort((a, b) => Number(b.updated ?? 0) - Number(a.updated ?? 0));
284
+
285
+ const cache = new Map<string, string>();
286
+ let total = 0;
287
+ let active = 0;
288
+ for (const ch of channels) {
289
+ const id = String(ch.id ?? "");
290
+ const name = typeof ch.name === "string" ? ch.name : id;
291
+ let hist: Record<string, Json>;
292
+ try {
293
+ hist = (await history(token, id, limit)) as Record<string, Json>;
294
+ } catch (e) {
295
+ console.error(` SKIP #${name}: ${(e as Error).message}`);
296
+ continue;
297
+ }
298
+ const msgs = asArray(hist.messages)
299
+ .map(asRecord)
300
+ .filter((m) => (m.subtype === undefined || m.subtype === null) && tsNum(m) >= cutoff);
301
+ if (msgs.length === 0) continue;
302
+ active += 1;
303
+ total += msgs.length;
304
+ console.log(`## #${name} (${msgs.length} msgs)\n`);
305
+ for (const m of [...msgs].reverse()) {
306
+ const who = await displayUser(token, m, cache);
307
+ const raw = typeof m.text === "string" ? m.text : "";
308
+ const resolved = resolveDateMarkup(await resolveMentions(token, raw, cache));
309
+ const oneline = resolved.split("\n").join(" ↵ ");
310
+ console.log(`[${formatYmdHm(tsNum(m))}] ${who}: ${oneline}`);
311
+ }
312
+ console.log("");
313
+ }
314
+ console.error(`Dumped ${total} messages across ${active} channels (cutoff: ${days}d)`);
315
+ }
316
+
317
+ // Extract plain text from Slack rich-text blocks
318
+ function blocksToText(blocks: Json[]): string {
319
+ const parts: string[] = [];
320
+ function walk(node: Json): void {
321
+ if (typeof node === "string") { parts.push(node); return; }
322
+ if (Array.isArray(node)) { for (const n of node) walk(n); return; }
323
+ if (node && typeof node === "object") {
324
+ const obj = node as Record<string, Json>;
325
+ if (typeof obj.text === "string") { parts.push(obj.text); return; }
326
+ if (Array.isArray(obj.elements)) walk(obj.elements);
327
+ else if (Array.isArray(obj.content)) walk(obj.content);
328
+ }
329
+ }
330
+ for (const b of blocks) walk(b);
331
+ return parts.join("").trim();
332
+ }
333
+
334
+ // --- drafts helpers ---
335
+ async function buildChLabelResolver(
336
+ token: string,
337
+ cookie: string | undefined,
338
+ myUserId: string,
339
+ ): Promise<(channelId: string) => Promise<string>> {
340
+ const cache = new Map<string, string>();
341
+ return async (channelId: string): Promise<string> => {
342
+ if (!channelId) return "(unknown)";
343
+ if (cache.has(channelId)) return cache.get(channelId)!;
344
+ try {
345
+ const info = (await conversationInfoSession(token, channelId, cookie)) as Record<string, Json>;
346
+ const ch = asRecord(info.channel);
347
+ let label: string;
348
+ if (ch.is_im === true) {
349
+ const uid = typeof ch.user === "string" ? ch.user : "";
350
+ if (!uid || uid === myUserId) {
351
+ label = "@self";
352
+ } else {
353
+ const [display] = await userInfoPair(token, uid);
354
+ label = `@${display}`;
355
+ }
356
+ } else {
357
+ label = `#${typeof ch.name === "string" ? ch.name : channelId}`;
358
+ }
359
+ cache.set(channelId, label);
360
+ return label;
361
+ } catch {
362
+ return channelId;
363
+ }
364
+ };
365
+ }
366
+
367
+ function draftChannelId(d: Record<string, Json>): string {
368
+ const dest = asRecord(asArray(d.destinations)[0]);
369
+ return String(dest.channel_id ?? d.channel_id ?? d.channel ?? "");
370
+ }
371
+
372
+ function draftText(d: Record<string, Json>): string {
373
+ return blocksToText(asArray(d.blocks)) || (typeof d.text === "string" ? d.text : "(no text)");
374
+ }
375
+
376
+ // --- drafts list ---
377
+ async function cmdDrafts(token: string, cookie?: string, showAll = false): Promise<void> {
378
+ const resp = (await listDrafts(token, cookie)) as Record<string, Json>;
379
+ const all = asArray(resp.drafts ?? resp.messages ?? [])
380
+ .map(asRecord)
381
+ .filter((d) => d.is_deleted !== true);
382
+ const drafts = showAll ? all : all.filter((d) => d.is_sent !== true);
383
+
384
+ if (drafts.length === 0) {
385
+ console.log(showAll ? "No drafts." : "No pending drafts. Run with --all to include sent.");
386
+ return;
387
+ }
388
+
389
+ let myUserId = "";
390
+ try { ({ userId: myUserId } = await authTestSession(token, cookie)); } catch { /* best-effort */ }
391
+ const resolveChLabel = await buildChLabelResolver(token, cookie, myUserId);
392
+ const mentionCache = new Map<string, string>();
393
+
394
+ for (const d of drafts) {
395
+ const channelId = draftChannelId(d);
396
+ const text = draftText(d);
397
+ const ts = Number(d.date_created ?? d.date_create ?? 0);
398
+ const stamp = ts ? formatYmdHmsUtc(ts) : "?";
399
+ const chLabel = await resolveChLabel(channelId);
400
+ const id = typeof d.id === "string" ? d.id : "";
401
+ const sentTag = d.is_sent === true ? " [SENT]" : "";
402
+ const resolved = resolveDateMarkup(await resolveMentions(token, text, mentionCache));
403
+ console.log(`── ${id} ${chLabel} [${stamp}]${sentTag}`);
404
+ for (const line of resolved.split("\n")) console.log(` ${line}`);
405
+ }
406
+ }
407
+
408
+ // --- drafts get ---
409
+ async function cmdDraftGet(token: string, cookie: string | undefined, draftId: string): Promise<void> {
410
+ const resp = (await listDrafts(token, cookie)) as Record<string, Json>;
411
+ const d = asArray(resp.drafts).map(asRecord).find((x) => String(x.id) === draftId);
412
+ if (!d) { console.error(`Draft not found: ${draftId}`); process.exit(1); }
413
+
414
+ let myUserId = "";
415
+ try { ({ userId: myUserId } = await authTestSession(token, cookie)); } catch { /* best-effort */ }
416
+ const resolveChLabel = await buildChLabelResolver(token, cookie, myUserId);
417
+
418
+ const channelId = draftChannelId(d);
419
+ const text = draftText(d);
420
+ const ts = Number(d.date_created ?? 0);
421
+ const updatedTs = String(d.last_updated_ts ?? "?");
422
+ const chLabel = await resolveChLabel(channelId);
423
+
424
+ const cache = new Map<string, string>();
425
+ const resolved = resolveDateMarkup(await resolveMentions(token, text, cache));
426
+
427
+ console.log(`id: ${d.id}`);
428
+ console.log(`channel: ${chLabel} (${channelId})`);
429
+ console.log(`created: ${ts ? formatYmdHmsUtc(ts) : "?"}`);
430
+ console.log(`updated: ${formatYmdHmsUtc(Number(updatedTs.split(".")[0]))}`);
431
+ console.log(`status: ${d.is_sent === true ? "sent" : "pending"}`);
432
+ console.log(`---`);
433
+ console.log(resolved);
434
+ }
435
+
436
+ /** Compute a 4-char hex safety code from arbitrary context strings. */
437
+ function safetyCode(...parts: string[]): string {
438
+ return sha256Hex(parts.join("\n")).slice(0, 4);
439
+ }
440
+
441
+ /** Dry-run gate: print context, print required --code=, exit 1.
442
+ * Call this when --code is absent or wrong. */
443
+ function requireCode(provided: string | undefined, expected: string, contextLines: string[]): void {
444
+ for (const line of contextLines) console.log(line);
445
+ if (provided !== undefined) {
446
+ console.error(`Code mismatch (got ${provided}, expected ${expected})`);
447
+ }
448
+ console.error(`Rerun with --code=${expected}`);
449
+ process.exit(1);
450
+ }
451
+
452
+ /** Split a target ref that may embed a message ts.
453
+ * Accepts: `#chan:ts`, `@user:ts`, Slack permalink URL, or plain ref. */
454
+ function splitRefTs(s: string): { ref: string; ts?: string } {
455
+ const url = parseSlackPermalink(s);
456
+ if (url) return url.ts ? { ref: url.channel, ts: url.ts } : { ref: url.channel };
457
+ if (s.startsWith("#") || s.startsWith("@")) {
458
+ const colon = s.indexOf(":");
459
+ if (colon > 0) {
460
+ const maybeTs = s.slice(colon + 1);
461
+ if (/^\d{10}\.\d{6}$/.test(maybeTs)) {
462
+ return { ref: s.slice(0, colon), ts: maybeTs };
463
+ }
464
+ }
465
+ }
466
+ return { ref: s };
467
+ }
468
+
469
+ // --- edit ---
470
+ interface EditArgs {
471
+ target: string;
472
+ newText: string;
473
+ code?: string;
474
+ channelId?: string;
475
+ }
476
+ async function cmdEdit(token: string, args: EditArgs): Promise<void> {
477
+ const { ref, ts } = splitRefTs(args.target);
478
+ if (!ts) {
479
+ console.error("Error: target must embed a message ts (e.g. #chan:1700000000.000100 or a Slack permalink URL)");
480
+ process.exit(2);
481
+ }
482
+
483
+ let channelId: string;
484
+ if (args.channelId) channelId = args.channelId;
485
+ else channelId = await resolveChannel(token, ref);
486
+
487
+ // Fetch the message to display the original text and compute the safety hash.
488
+ const resp = (await replies(token, channelId, ts, 1)) as Record<string, Json>;
489
+ const msgs = asArray(resp.messages).map(asRecord);
490
+ const original = msgs.find((m) => String(m.ts) === ts);
491
+ if (!original) {
492
+ console.error(`Message not found at ts=${ts} in channel ${channelId}`);
493
+ process.exit(1);
494
+ }
495
+ const originalText = typeof original.text === "string" ? original.text : "";
496
+
497
+ const code = safetyCode(originalText, args.newText);
498
+ if (args.code !== code) {
499
+ requireCode(args.code, code, [
500
+ `─── Original message ─────────────────────────`,
501
+ ...originalText.split("\n").map((l) => ` ${l}`),
502
+ `─── Replacing with ───────────────────────────`,
503
+ ...args.newText.split("\n").map((l) => ` ${l}`),
504
+ `─────────────────────────────────────────────`,
505
+ ]);
506
+ }
507
+
508
+ const newTs = await editMessage(token, channelId, ts, args.newText);
509
+ console.log(`✓ Edited (ts: ${newTs})`);
510
+ }
511
+
512
+ // --- send ---
513
+ interface SendArgs {
514
+ target: string;
515
+ message: string;
516
+ thread?: string;
517
+ code?: string;
518
+ channelId?: string;
519
+ userId?: string;
520
+ }
521
+ async function cmdSend(token: string, args: SendArgs): Promise<void> {
522
+ let channelId: string;
523
+ if (args.channelId) channelId = args.channelId;
524
+ else if (args.userId) channelId = await openDm(token, args.userId);
525
+ else if (args.target.startsWith("#") || args.target.startsWith("@")) {
526
+ channelId = await resolveChannel(token, args.target);
527
+ } else {
528
+ console.error(`Error: target must be #channel-name or @username (got: ${args.target})`);
529
+ console.error("Use --channel-id=<ID> or --user-id=<ID> to send by raw ID.");
530
+ process.exit(1);
531
+ }
532
+
533
+ // Fetch last 1 message for context hash
534
+ const ctx = (await history(token, channelId, 1)) as Record<string, Json>;
535
+ const lastMsg = asArray(ctx.messages).map(asRecord)
536
+ .filter((m) => m.subtype === undefined || m.subtype === null)[0];
537
+ const lastText = typeof lastMsg?.text === "string" ? lastMsg.text : "";
538
+ const lastUser = typeof lastMsg?.user === "string" ? lastMsg.user : "?";
539
+
540
+ const code = safetyCode(lastText, args.message);
541
+
542
+ if (args.code !== code) {
543
+ requireCode(args.code, code, [
544
+ `─── Last message in channel ──────────────────`,
545
+ ` ${lastUser}: ${lastText.split("\n")[0]?.slice(0, 100) ?? "(empty)"}`,
546
+ `─── Sending ──────────────────────────────────`,
547
+ ` To: ${args.target}${args.thread ? ` (thread ${args.thread})` : ""}`,
548
+ ` Message: ${args.message}`,
549
+ `─────────────────────────────────────────────`,
550
+ ]);
551
+ }
552
+ const ts = await slackSend(token, channelId, args.message, args.thread);
553
+ console.log(`✓ Sent (ts: ${ts})`);
554
+ }
555
+
556
+ // --- upload ---
557
+ interface UploadArgs {
558
+ target: string;
559
+ filePath: string;
560
+ title?: string;
561
+ thread?: string;
562
+ comment?: string;
563
+ code?: string;
564
+ channelId?: string;
565
+ userId?: string;
566
+ }
567
+ async function cmdUpload(token: string, args: UploadArgs): Promise<void> {
568
+ const { statSync, existsSync } = await import("node:fs");
569
+ const { basename } = await import("node:path");
570
+
571
+ if (!existsSync(args.filePath)) {
572
+ console.error(`Error: file not found: ${args.filePath}`);
573
+ process.exit(1);
574
+ }
575
+ const stat = statSync(args.filePath);
576
+
577
+ let channelId: string;
578
+ if (args.channelId) channelId = args.channelId;
579
+ else if (args.userId) channelId = await openDm(token, args.userId);
580
+ else if (args.target.startsWith("#") || args.target.startsWith("@")) {
581
+ channelId = await resolveChannel(token, args.target);
582
+ } else {
583
+ console.error(`Error: target must be #channel-name or @username (got: ${args.target})`);
584
+ process.exit(1);
585
+ }
586
+
587
+ const filename = basename(args.filePath);
588
+ const title = args.title ?? filename;
589
+ const sizeFmt = stat.size < 1024
590
+ ? `${stat.size} B`
591
+ : stat.size < 1048576
592
+ ? `${(stat.size / 1024).toFixed(1)} KB`
593
+ : `${(stat.size / 1048576).toFixed(1)} MB`;
594
+
595
+ const code = safetyCode(channelId, args.filePath, title);
596
+ if (args.code !== code) {
597
+ requireCode(args.code, code, [
598
+ `─── Uploading file ───────────────────────────`,
599
+ ` To: ${args.target}${args.thread ? ` (thread ${args.thread})` : ""}`,
600
+ ` File: ${args.filePath}`,
601
+ ` Title: ${title}`,
602
+ ` Size: ${sizeFmt}`,
603
+ `─────────────────────────────────────────────`,
604
+ ]);
605
+ }
606
+
607
+ const uploadOpts: { title?: string; threadTs?: string; initialComment?: string } = { title };
608
+ if (args.thread !== undefined) uploadOpts.threadTs = args.thread;
609
+ if (args.comment !== undefined) uploadOpts.initialComment = args.comment;
610
+ const { fileId, permalink } = await uploadFile(token, channelId, args.filePath, uploadOpts);
611
+ console.log(`✓ Uploaded (file_id: ${fileId}${permalink ? `, url: ${permalink}` : ""})`);
612
+ }
613
+
614
+ // --- dispatch ---
615
+ function usage(): never {
616
+ console.error(
617
+ [
618
+ "Usage: slack [--workspace=<name>] <command> [args]",
619
+ "Commands:",
620
+ " msgs [<#channel|@user|url>] [-n|--limit N]",
621
+ " thread <#channel|@user|url> <ts> [-n|--limit N]",
622
+ " channels [-n|--limit N] [--filter STR] [--all]",
623
+ " news [-l|--limit N]",
624
+ " search <query> [-n|--count N]",
625
+ " drafts [--all] list pending drafts (--all includes sent)",
626
+ " drafts new <#channel|@user> <text>",
627
+ " drafts get <draft-id>",
628
+ " drafts edit <draft-id> [--code=XXXX] <new-text>",
629
+ " drafts delete <draft-id> [--code=XXXX]",
630
+ " send <target> <message> [--thread TS] [--code XXXX] [--channel-id ID] [--user-id ID]",
631
+ " edit <#chan:ts|@user:ts|url> <new-text> [--code XXXX] [--channel-id ID]",
632
+ " upload <target> <file> [--title TEXT] [--thread TS] [--comment TEXT] [--code XXXX] [--channel-id ID] [--user-id ID]",
633
+ " dump [-d|--days N] [-l|--limit N] [-f|--filter STR]",
634
+ " workspace ls|list",
635
+ " workspace import (auto-import from Slack desktop app)",
636
+ " workspace add <name> <token>",
637
+ " workspace set-token <name> <token>",
638
+ " workspace set-cookie <name> <xoxd> (store xoxd cookie for draft API)",
639
+ " workspace use <name>",
640
+ " workspace remove <name>",
641
+ " workspace current",
642
+ ].join("\n"),
643
+ );
644
+ process.exit(2);
645
+ }
646
+
647
+ async function cmdWorkspace(sub: string, args: string[]): Promise<void> {
648
+ switch (sub) {
649
+ case "list":
650
+ case "ls": {
651
+ const profiles = listProfiles();
652
+ if (profiles.length === 0) {
653
+ console.log("No workspaces configured.");
654
+ console.log("Import from the Slack desktop app: slack workspace import");
655
+ console.log("Or add manually: slack workspace add <name> <token>");
656
+ return;
657
+ }
658
+ for (const { name, profile, current } of profiles) {
659
+ console.log(`${current ? "* " : " "}${name} ${profile.team} (${profile.user}) ${profile.url ?? ""}`);
660
+ }
661
+ return;
662
+ }
663
+ case "import": {
664
+ console.error("Scanning Slack desktop app...");
665
+ const sessions = await extractSessions();
666
+ if (sessions.length === 0) {
667
+ console.error("No sessions found. Make sure Slack is installed and you have logged in.");
668
+ return;
669
+ }
670
+ for (const s of sessions) {
671
+ const teamLabel = s.teamName ?? s.teamId;
672
+ console.error(` Found: ${teamLabel} ${s.url ?? ""}`);
673
+ const name = teamLabel.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
674
+ const profile: Parameters<typeof addProfile>[1] = {
675
+ token: s.token,
676
+ team: s.teamName ?? s.teamId,
677
+ teamId: s.teamId,
678
+ url: s.url ?? "",
679
+ user: "",
680
+ };
681
+ if (s.cookie) profile.cookie = s.cookie;
682
+ addProfile(name, profile);
683
+ const cookieNote = s.cookie ? " + xoxd cookie" : "";
684
+ console.log(`Added workspace "${name}": ${teamLabel}${cookieNote}`);
685
+ }
686
+ console.log(`\nNote: desktop app tokens (xoxc-) are internal Slack tokens.`);
687
+ console.log(`If API calls fail, replace with an xoxp- token:`);
688
+ console.log(` slack workspace set-token <name> <xoxp-token>`);
689
+ console.log(`\nDone. Run: slack workspace ls`);
690
+ return;
691
+ }
692
+ case "add": {
693
+ const [name, token] = args;
694
+ if (!name || !token) {
695
+ console.error("Usage: slack workspace add <name> <token>");
696
+ process.exit(2);
697
+ }
698
+ console.error(`Verifying token...`);
699
+ const info = await authTest(token);
700
+ addProfile(name, { token, ...info });
701
+ console.log(`Added workspace "${name}": ${info.team} (${info.user})`);
702
+ return;
703
+ }
704
+ case "set-token": {
705
+ const [name, token] = args;
706
+ if (!name || !token) {
707
+ console.error("Usage: slack workspace set-token <name> <token>");
708
+ console.error(" Updates the token for an existing workspace profile.");
709
+ process.exit(2);
710
+ }
711
+ console.error(`Verifying token...`);
712
+ const info = await authTest(token);
713
+ addProfile(name, { token, ...info });
714
+ console.log(`Updated workspace "${name}": ${info.team} (${info.user})`);
715
+ return;
716
+ }
717
+ case "set-cookie": {
718
+ const [name, xoxd] = args;
719
+ if (!name || !xoxd) {
720
+ console.error("Usage: slack workspace set-cookie <name> <xoxd-value>");
721
+ console.error(" Stores the xoxd session cookie for draft API access.");
722
+ console.error(" Get it from: DevTools → Application → Cookies → slack.com → d");
723
+ process.exit(2);
724
+ }
725
+ const cookieVal = xoxd.startsWith("d=") ? xoxd.slice(2) : xoxd;
726
+ setCookie(name, cookieVal);
727
+ console.log(`Cookie set for workspace "${name}". Run: slack drafts`);
728
+ return;
729
+ }
730
+ case "use": {
731
+ const globalFlag = args.includes("-g");
732
+ const name = args.find((a) => a !== "-g");
733
+ if (!name) {
734
+ console.error("Usage: slack workspace use [-g] <name>");
735
+ console.error(" -g write to ~/.slack-cli/workspace (global)");
736
+ console.error(" default: write to .slack-cli/workspace (local cwd)");
737
+ process.exit(2);
738
+ }
739
+ if (!globalFlag) ensureSlackCliDir(join(process.cwd(), ".slack-cli"));
740
+ useProfile(name, globalFlag);
741
+ const scope = globalFlag ? "globally (~/.slack-cli/workspace)" : "locally (.slack-cli/workspace)";
742
+ console.log(`Switched to workspace "${name}" ${scope}`);
743
+ if (!globalFlag) console.log(`Tip: add .slack-cli/ to your .gitignore`);
744
+ return;
745
+ }
746
+ case "remove": {
747
+ const [name] = args;
748
+ if (!name) { console.error("Usage: slack workspace remove <name>"); process.exit(2); }
749
+ removeProfile(name);
750
+ console.log(`Removed workspace "${name}"`);
751
+ return;
752
+ }
753
+ case "current": {
754
+ const profiles = listProfiles();
755
+ const cur = profiles.find((p) => p.current);
756
+ if (!cur) { console.log("No workspace selected"); return; }
757
+ console.log(`${cur.name} ${cur.profile.team} (${cur.profile.user})`);
758
+ return;
759
+ }
760
+ default:
761
+ usage();
762
+ }
763
+ }
764
+
765
+ async function main(): Promise<void> {
766
+ loadDotenvFiles();
767
+
768
+ // Strip global --workspace=<name> flag before subcommand dispatch.
769
+ const rawArgs = process.argv.slice(2);
770
+ let workspaceFlag: string | undefined;
771
+ const filteredArgs: string[] = [];
772
+ for (const arg of rawArgs) {
773
+ const m = arg.match(/^--workspace=(.+)$/);
774
+ if (m) { workspaceFlag = m[1]; }
775
+ else filteredArgs.push(arg);
776
+ }
777
+ const [cmd, ...rest] = filteredArgs;
778
+ // workspace subcommand needs no token
779
+ if (cmd === "workspace") {
780
+ await cmdWorkspace(rest[0] ?? "", rest.slice(1));
781
+ return;
782
+ }
783
+
784
+ const token = resolveToken(workspaceFlag);
785
+ const cookie = resolveCookie(workspaceFlag);
786
+
787
+ switch (cmd) {
788
+ case "msgs": {
789
+ const { values, positionals } = parseArgs({
790
+ args: rest,
791
+ allowPositionals: true,
792
+ options: { limit: { type: "string", short: "n", default: "20" } },
793
+ strict: true,
794
+ });
795
+ if (positionals[0]) {
796
+ await cmdMsgsTarget(token, positionals[0], Number(values.limit));
797
+ } else {
798
+ await cmdMsgs(token);
799
+ }
800
+ return;
801
+ }
802
+ case "thread": {
803
+ const { values, positionals } = parseArgs({
804
+ args: rest,
805
+ allowPositionals: true,
806
+ options: { limit: { type: "string", short: "n", default: "100" } },
807
+ strict: true,
808
+ });
809
+ const target = positionals[0];
810
+ const ts = positionals[1];
811
+ if (!target || !ts) usage();
812
+ await cmdThread(token, target, ts, Number(values.limit));
813
+ return;
814
+ }
815
+ case "channels": {
816
+ const { values } = parseArgs({
817
+ args: rest,
818
+ options: {
819
+ limit: { type: "string", short: "n", default: "200" },
820
+ filter: { type: "string", short: "f" },
821
+ all: { type: "boolean" },
822
+ },
823
+ strict: true,
824
+ });
825
+ await cmdChannels(token, Number(values.limit), values.filter, values.all);
826
+ return;
827
+ }
828
+ case "news": {
829
+ const { values } = parseArgs({
830
+ args: rest,
831
+ options: { limit: { type: "string", short: "l", default: "20" } },
832
+ strict: true,
833
+ });
834
+ await cmdNews(token, Number(values.limit));
835
+ return;
836
+ }
837
+ case "drafts": {
838
+ const sub = rest[0];
839
+ if (sub === "new" || sub === "save") {
840
+ // drafts new <#channel|@user> <text...>
841
+ const args2 = rest.slice(1);
842
+ const target = args2[0];
843
+ const text = args2.slice(1).join(" ");
844
+ if (!target || !text) {
845
+ console.error("Usage: slack drafts new <#channel|@user> <text>");
846
+ process.exit(2);
847
+ }
848
+ const channelId = await resolveChannel(token, target, cookie);
849
+ const resp = (await createDraft(token, channelId, text, cookie)) as Record<string, Json>;
850
+ console.log(`✓ Draft created (id: ${asRecord(resp.draft).id ?? "?"})`);
851
+ } else if (sub === "get") {
852
+ const draftId = rest[1];
853
+ if (!draftId) { console.error("Usage: slack drafts get <draft-id>"); process.exit(2); }
854
+ await cmdDraftGet(token, cookie, draftId);
855
+ } else if (sub === "edit" || sub === "update") {
856
+ // drafts edit <draft-id> [--code=XXXX] <text...>
857
+ const draftId = rest[1];
858
+ const codeArg = rest.find((a) => a.startsWith("--code="))?.slice(7);
859
+ const textParts = rest.slice(2).filter((a) => !a.startsWith("--code="));
860
+ const text = textParts.join(" ");
861
+ if (!draftId || !text) {
862
+ console.error("Usage: slack drafts edit <draft-id> <new-text>");
863
+ process.exit(2);
864
+ }
865
+ const listResp = (await listDrafts(token, cookie)) as Record<string, Json>;
866
+ const d = asArray(listResp.drafts).map(asRecord).find((x) => String(x.id) === draftId);
867
+ if (!d) { console.error(`Draft not found: ${draftId}`); process.exit(1); }
868
+ const prevText = draftText(d);
869
+ const code = safetyCode(prevText, text);
870
+ if (codeArg !== code) {
871
+ requireCode(codeArg, code, [
872
+ `─── Current draft content ────────────────────`,
873
+ ...prevText.split("\n").map((l) => ` ${l}`),
874
+ `─── Replacing with ───────────────────────────`,
875
+ ...text.split("\n").map((l) => ` ${l}`),
876
+ `─────────────────────────────────────────────`,
877
+ ]);
878
+ }
879
+ const channelId = draftChannelId(d);
880
+ const resp = (await updateDraft(token, draftId, channelId, text, cookie)) as Record<string, Json>;
881
+ console.log(`✓ Draft updated (id: ${asRecord(resp.draft).id ?? "?"})`);
882
+ } else if (sub === "delete" || sub === "rm") {
883
+ const draftId = rest[1];
884
+ const codeArg = rest.find((a) => a.startsWith("--code="))?.slice(7);
885
+ if (!draftId) { console.error("Usage: slack drafts delete <draft-id>"); process.exit(2); }
886
+ const listResp = (await listDrafts(token, cookie)) as Record<string, Json>;
887
+ const d = asArray(listResp.drafts).map(asRecord).find((x) => String(x.id) === draftId);
888
+ if (!d) { console.error(`Draft not found: ${draftId}`); process.exit(1); }
889
+ const prevText = draftText(d);
890
+ const code = safetyCode(draftId, prevText);
891
+ if (codeArg !== code) {
892
+ requireCode(codeArg, code, [
893
+ `─── Deleting draft ───────────────────────────`,
894
+ ` id: ${draftId}`,
895
+ ...prevText.split("\n").map((l) => ` ${l}`),
896
+ `─────────────────────────────────────────────`,
897
+ ]);
898
+ }
899
+ await deleteDraft(token, draftId, cookie);
900
+ console.log(`✓ Draft deleted (id: ${draftId})`);
901
+ } else {
902
+ const showAll = rest.includes("--all") || rest.includes("-a");
903
+ await cmdDrafts(token, cookie, showAll);
904
+ }
905
+ return;
906
+ }
907
+ case "search": {
908
+ const { values, positionals } = parseArgs({
909
+ args: rest,
910
+ allowPositionals: true,
911
+ options: { count: { type: "string", short: "n", default: "100" } },
912
+ strict: true,
913
+ });
914
+ const query = positionals[0];
915
+ if (!query) usage();
916
+ await cmdSearch(token, query, Number(values.count));
917
+ return;
918
+ }
919
+ case "send": {
920
+ const { values, positionals } = parseArgs({
921
+ args: rest,
922
+ allowPositionals: true,
923
+ options: {
924
+ thread: { type: "string" },
925
+ code: { type: "string" },
926
+ "channel-id": { type: "string" },
927
+ "user-id": { type: "string" },
928
+ },
929
+ strict: true,
930
+ });
931
+ const target = positionals[0];
932
+ const message = positionals[1];
933
+ if (!target || !message) usage();
934
+ const sendArgs: SendArgs = { target, message };
935
+ if (values.thread !== undefined) sendArgs.thread = values.thread;
936
+ if (values.code !== undefined) sendArgs.code = values.code;
937
+ if (values["channel-id"] !== undefined) sendArgs.channelId = values["channel-id"];
938
+ if (values["user-id"] !== undefined) sendArgs.userId = values["user-id"];
939
+ await cmdSend(token, sendArgs);
940
+ return;
941
+ }
942
+ case "edit": {
943
+ const { values, positionals } = parseArgs({
944
+ args: rest,
945
+ allowPositionals: true,
946
+ options: {
947
+ code: { type: "string" },
948
+ "channel-id": { type: "string" },
949
+ },
950
+ strict: true,
951
+ });
952
+ const target = positionals[0];
953
+ const newText = positionals[1];
954
+ if (!target || !newText) usage();
955
+ const editArgs: EditArgs = { target, newText };
956
+ if (values.code !== undefined) editArgs.code = values.code;
957
+ if (values["channel-id"] !== undefined) editArgs.channelId = values["channel-id"];
958
+ await cmdEdit(token, editArgs);
959
+ return;
960
+ }
961
+ case "upload": {
962
+ const { values, positionals } = parseArgs({
963
+ args: rest,
964
+ allowPositionals: true,
965
+ options: {
966
+ title: { type: "string" },
967
+ thread: { type: "string" },
968
+ comment: { type: "string" },
969
+ code: { type: "string" },
970
+ "channel-id": { type: "string" },
971
+ "user-id": { type: "string" },
972
+ },
973
+ strict: true,
974
+ });
975
+ const target = positionals[0];
976
+ const filePath = positionals[1];
977
+ if (!target || !filePath) usage();
978
+ const uploadArgs: UploadArgs = { target, filePath };
979
+ if (values.title !== undefined) uploadArgs.title = values.title;
980
+ if (values.thread !== undefined) uploadArgs.thread = values.thread;
981
+ if (values.comment !== undefined) uploadArgs.comment = values.comment;
982
+ if (values.code !== undefined) uploadArgs.code = values.code;
983
+ if (values["channel-id"] !== undefined) uploadArgs.channelId = values["channel-id"];
984
+ if (values["user-id"] !== undefined) uploadArgs.userId = values["user-id"];
985
+ await cmdUpload(token, uploadArgs);
986
+ return;
987
+ }
988
+ case "dump": {
989
+ const { values } = parseArgs({
990
+ args: rest,
991
+ options: {
992
+ days: { type: "string", short: "d", default: "7" },
993
+ limit: { type: "string", short: "l", default: "200" },
994
+ filter: { type: "string", short: "f" },
995
+ },
996
+ strict: true,
997
+ });
998
+ await cmdDump(token, Number(values.days), Number(values.limit), values.filter);
999
+ return;
1000
+ }
1001
+ default:
1002
+ usage();
1003
+ }
1004
+ }
1005
+
1006
+ main().catch((e: unknown) => {
1007
+ const msg = e instanceof Error ? e.message : String(e);
1008
+ console.error(`Error: ${msg}`);
1009
+ process.exit(1);
1010
+ });