slack-term 1.7.0 → 1.14.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 CHANGED
@@ -1,17 +1,18 @@
1
1
  #!/usr/bin/env bun
2
2
  // Slack CLI entry — mirrors the Rust impl in src/main.rs.
3
3
 
4
- import { parseArgs } from "node:util";
5
4
  import { createHash } from "node:crypto";
6
5
  import { existsSync, readFileSync, mkdirSync, writeFileSync } from "node:fs";
7
6
  import { homedir } from "node:os";
8
7
  import { join } from "node:path";
9
8
 
10
- import { addProfile, listProfiles, removeProfile, resolveCookie, resolveToken, setCookie, useProfile } from "./profiles.ts";
11
- import { extractSessions } from "./slack-app.ts";
9
+ import yargs from "yargs";
10
+ import { hideBin } from "yargs/helpers";
11
+ import { listProfiles, removeProfile, resolveCookie, resolveToken, useProfile } from "./profiles.ts";
12
+ import { cmdAuthLogin } from "./auth.ts";
13
+ import { cmdTail } from "./tail.ts";
12
14
 
13
15
  import {
14
- authTest,
15
16
  authTestSession,
16
17
  conversationInfoSession,
17
18
  createDraft,
@@ -21,6 +22,9 @@ import {
21
22
  history,
22
23
  listConversations,
23
24
  listDrafts,
25
+ listUsers,
26
+ userInfo,
27
+ conversationInfo,
24
28
  openDm,
25
29
  parseSlackPermalink,
26
30
  replies,
@@ -28,13 +32,16 @@ import {
28
32
  search,
29
33
  searchAll,
30
34
  send as slackSend,
35
+ scheduleMessage,
36
+ listScheduledMessages,
37
+ deleteScheduledMessage,
31
38
  uploadFile,
32
39
  userInfoPair,
33
40
  userName,
34
41
  getPath,
35
42
  type Json,
36
43
  } from "./slack.ts";
37
- import { dayLabel, formatHm, formatYmdHm, resolveDateMarkup, resolveMentions } from "./format.ts";
44
+ import { dayLabel, formatYmdHm, resolveDateMarkup, resolveMentions } from "./format.ts";
38
45
 
39
46
  function loadDotenv(path: string): void {
40
47
  if (!existsSync(path)) return;
@@ -109,52 +116,87 @@ function formatYmdHmsUtc(epochSec: number): string {
109
116
  return `${y}-${mo}-${da} ${h}:${mi}:${s}`;
110
117
  }
111
118
 
112
- // Format one message line: `[YYYY-MM-DD HH:MM:SS] <real|@handle> text` (UTC)
119
+ // Lossless Slack ts → ISO string: "2026-05-11T06:01:04.000100"
120
+ function slackTsToIso(tsRaw: string): string {
121
+ const [secStr, fracStr = "000000"] = tsRaw.split(".");
122
+ const epochSec = Number(secStr);
123
+ const d = new Date(epochSec * 1000);
124
+ const y = d.getUTCFullYear();
125
+ const mo = String(d.getUTCMonth() + 1).padStart(2, "0");
126
+ const da = String(d.getUTCDate()).padStart(2, "0");
127
+ const h = String(d.getUTCHours()).padStart(2, "0");
128
+ const mi = String(d.getUTCMinutes()).padStart(2, "0");
129
+ const s = String(d.getUTCSeconds()).padStart(2, "0");
130
+ const frac = fracStr.padEnd(6, "0").slice(0, 6);
131
+ return `${y}-${mo}-${da}T${h}:${mi}:${s}.${frac}`;
132
+ }
133
+
134
+ // Parse ISO ts back to Slack ts — throws if fractional is absent or not exactly 6 digits.
135
+ function isoToSlackTs(iso: string): string {
136
+ const m = iso.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})\.(\d{6})$/);
137
+ if (!m) throw new Error(`Invalid message timestamp "${iso}" — expected 2026-05-11T06:01:04.000100`);
138
+ const epochMs = Date.parse(m[1] + "Z");
139
+ if (isNaN(epochMs)) throw new Error(`Cannot parse date in "${iso}"`);
140
+ return `${Math.floor(epochMs / 1000)}.${m[2]}`;
141
+ }
142
+
143
+ function parseInputTs(s: string): string {
144
+ return /^\d{4}-\d{2}-\d{2}T/.test(s) ? isoToSlackTs(s) : s;
145
+ }
146
+
147
+ // Format one message line: `[ts] @handle text` (UTC). Pass chLabel (e.g. "#general") for search.
113
148
  async function formatMsgLine(
114
149
  token: string,
115
150
  m: Record<string, Json>,
116
151
  cache: Map<string, string>,
152
+ chLabel?: string,
117
153
  ): Promise<string> {
118
- const ts = tsNum(m);
119
- const stamp = formatYmdHmsUtc(ts);
120
- let real = "?";
154
+ const rawTs = typeof m.ts === "string" ? m.ts : `${tsNum(m)}.000000`;
155
+ const stamp = slackTsToIso(rawTs);
121
156
  let handle = "?";
122
157
  if (typeof m.user === "string") {
123
158
  const uid = m.user;
124
- const realKey = uid;
125
159
  const handleKey = "@" + uid;
126
- if (!cache.has(realKey) || !cache.has(handleKey)) {
127
- const [d, h] = await userInfoPair(token, uid);
128
- cache.set(realKey, d);
160
+ if (!cache.has(handleKey)) {
161
+ const [, h] = await userInfoPair(token, uid);
129
162
  cache.set(handleKey, h);
130
163
  }
131
- real = cache.get(realKey) ?? uid;
132
164
  handle = cache.get(handleKey) ?? uid;
133
165
  } else if (typeof m.username === "string") {
134
- real = m.username;
135
166
  handle = m.username;
136
167
  }
137
168
  const raw = typeof m.text === "string" ? m.text : "";
138
169
  const resolved = resolveDateMarkup(await resolveMentions(token, raw, cache));
139
- const oneline = resolved.split("\n").join(" ↵ ");
140
- return `[${stamp}] <${real}|@${handle}> ${oneline}`;
170
+ const lines = resolved.split("\n");
171
+ const body = lines[0] + (lines.length > 1 ? "\n" + lines.slice(1).map(l => ` ${l}`).join("\n") : "");
172
+ const who = chLabel ? `${chLabel} @${handle}` : `@${handle}`;
173
+ return `${stamp} ${who}: ${body}`;
141
174
  }
142
175
 
143
176
  // --- msgs <target> — channel/DM history with timestamps ---
144
177
  async function cmdMsgsTarget(token: string, target: string, limit: number): Promise<void> {
178
+ const parsed = parseSlackPermalink(target);
145
179
  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
180
  const cache = new Map<string, string>();
149
- for (const m of msgs.reverse()) {
150
- console.log(await formatMsgLine(token, m, cache));
181
+ if (parsed?.threadTs) {
182
+ const resp = (await replies(token, channelId, parsed.threadTs, limit)) as Record<string, Json>;
183
+ const msgs = asArray(resp.messages).map(asRecord);
184
+ for (const m of msgs) {
185
+ console.log(await formatMsgLine(token, m, cache));
186
+ }
187
+ } else {
188
+ const hist = (await history(token, channelId, limit)) as Record<string, Json>;
189
+ const msgs = asArray(hist.messages).map(asRecord);
190
+ for (const m of msgs.reverse()) {
191
+ console.log(await formatMsgLine(token, m, cache));
192
+ }
151
193
  }
152
194
  }
153
195
 
154
196
  // --- thread ---
155
197
  async function cmdThread(token: string, target: string, ts: string, limit: number): Promise<void> {
156
198
  const channelId = await resolveChannel(token, target);
157
- const resp = (await replies(token, channelId, ts, limit)) as Record<string, Json>;
199
+ const resp = (await replies(token, channelId, parseInputTs(ts), limit)) as Record<string, Json>;
158
200
  const msgs = asArray(resp.messages).map(asRecord);
159
201
  const cache = new Map<string, string>();
160
202
  for (const m of msgs) {
@@ -183,9 +225,10 @@ async function cmdMsgs(token: string): Promise<void> {
183
225
  const t = typeof m.text === "string" ? m.text : "";
184
226
  return t.length > 0 && !t.startsWith("<@");
185
227
  })
186
- .slice(0, 3);
228
+ .slice(0, 3)
229
+ .reverse();
187
230
  if (msgs.length === 0) continue;
188
- console.log(`── #${name} ─────────────────────────────────`);
231
+ console.log(`-- #${name} --------------------------------`);
189
232
  for (const m of msgs) {
190
233
  const who = await displayUser(token, m, cache);
191
234
  const raw = (typeof m.text === "string" ? m.text : "").split("\n")[0] ?? "";
@@ -198,44 +241,49 @@ async function cmdMsgs(token: string): Promise<void> {
198
241
  // --- news ---
199
242
  async function cmdNews(token: string, limit: number): Promise<void> {
200
243
  const resp = (await search(token, "to:me")) as Record<string, Json>;
201
- const matches = asArray(getPath(resp, ["messages", "matches"])).map(asRecord);
244
+ const matches = asArray(getPath(resp, ["messages", "matches"])).map(asRecord).slice(0, limit);
202
245
  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}`;
246
+
247
+ // Group by day (API returns newest-first; reverse within each group for chronological reading)
248
+ const groups: { label: string; msgs: Record<string, Json>[] }[] = [];
249
+ for (const m of matches) {
250
+ const label = dayLabel(tsNum(m));
251
+ const last = groups[groups.length - 1];
252
+ if (last?.label === label) last.msgs.push(m);
253
+ else groups.push({ label, msgs: [m] });
254
+ }
255
+
256
+ for (let gi = 0; gi < groups.length; gi++) {
257
+ const { label, msgs } = groups[gi]!;
258
+ if (gi > 0) console.log("");
259
+ console.log(` ${label}`);
260
+ console.log(" ----------------------------");
261
+ for (const m of [...msgs].reverse()) {
262
+ const ch = asRecord(m.channel);
263
+ const isIm = ch.is_im === true;
264
+ const rawName = typeof ch.name === "string" ? ch.name : "dm";
265
+ let chLabel: string;
266
+ if (isIm && rawName.startsWith("U")) {
267
+ const handleKey = "@" + rawName;
268
+ if (!cache.has(handleKey)) {
269
+ const [, h] = await userInfoPair(token, rawName);
270
+ cache.set(handleKey, h);
271
+ }
272
+ chLabel = `@${cache.get(handleKey) ?? rawName}`;
273
+ } else if (isIm) {
274
+ chLabel = `@${rawName}`;
275
+ } else {
276
+ chLabel = `#${rawName}`;
277
+ }
278
+ console.log(await formatMsgLine(token, m, cache, chLabel));
224
279
  }
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
280
  }
233
281
  }
234
282
 
235
283
  // --- channels ---
236
- async function cmdChannels(token: string, limit: number, filter?: string, all?: boolean): Promise<void> {
284
+ async function cmdChannels(token: string, limit: number, filter?: string, all?: boolean, format = "text"): Promise<void> {
237
285
  const resp = (await listConversations(token)) as Record<string, Json>;
238
- let channels = asArray(resp.channels)
286
+ const channels = asArray(resp.channels)
239
287
  .map(asRecord)
240
288
  .filter((c) => all || c.is_member === true)
241
289
  .filter((c) => {
@@ -246,6 +294,10 @@ async function cmdChannels(token: string, limit: number, filter?: string, all?:
246
294
  .sort((a, b) => Number(b.updated ?? 0) - Number(a.updated ?? 0))
247
295
  .slice(0, limit);
248
296
 
297
+ if (format === "jsonl") {
298
+ for (const ch of channels) console.log(JSON.stringify(ch));
299
+ return;
300
+ }
249
301
  for (const ch of channels) {
250
302
  const id = String(ch.id ?? "");
251
303
  const name = typeof ch.name === "string" ? ch.name : typeof ch.user === "string" ? ch.user : id;
@@ -253,14 +305,40 @@ async function cmdChannels(token: string, limit: number, filter?: string, all?:
253
305
  const isMpim = ch.is_mpim === true;
254
306
  const prefix = isIm || isMpim ? "@" : "#";
255
307
  const memberMark = ch.is_member === true ? "" : " (not joined)";
256
- console.log(`${prefix}${name}\t${id}${memberMark}`);
308
+ const purpose = typeof asRecord(ch.purpose).value === "string" ? String(asRecord(ch.purpose).value) : "";
309
+ const meta = purpose ? ` ${purpose.split("\n")[0]?.slice(0, 60)}` : "";
310
+ console.log(`${prefix}${name} ${id}${memberMark}${meta}`);
257
311
  }
258
312
  }
259
313
 
260
314
  // --- search ---
261
- async function cmdSearch(token: string, query: string, count: number): Promise<void> {
315
+ async function cmdSearch(token: string, query: string, count: number, json: boolean): Promise<void> {
262
316
  const resp = await searchAll(token, query, count);
263
- console.log(JSON.stringify(resp, null, 2));
317
+ if (json) {
318
+ console.log(JSON.stringify(resp, null, 2));
319
+ return;
320
+ }
321
+ const matches = asArray(getPath(resp as Record<string, Json>, ["messages", "matches"])).map(asRecord);
322
+ const cache = new Map<string, string>();
323
+ for (const m of matches) {
324
+ const ch = asRecord(m.channel);
325
+ const isIm = ch.is_im === true;
326
+ const rawName = typeof ch.name === "string" ? ch.name : "dm";
327
+ let chLabel: string;
328
+ if (isIm && rawName.startsWith("U")) {
329
+ const handleKey = "@" + rawName;
330
+ if (!cache.has(handleKey)) {
331
+ const [, h] = await userInfoPair(token, rawName);
332
+ cache.set(handleKey, h);
333
+ }
334
+ chLabel = `@${cache.get(handleKey) ?? rawName}`;
335
+ } else if (isIm) {
336
+ chLabel = `@${rawName}`;
337
+ } else {
338
+ chLabel = `#${rawName}`;
339
+ }
340
+ console.log(await formatMsgLine(token, m, cache, chLabel));
341
+ }
264
342
  }
265
343
 
266
344
  // --- dump ---
@@ -400,7 +478,7 @@ async function cmdDrafts(token: string, cookie?: string, showAll = false): Promi
400
478
  const id = typeof d.id === "string" ? d.id : "";
401
479
  const sentTag = d.is_sent === true ? " [SENT]" : "";
402
480
  const resolved = resolveDateMarkup(await resolveMentions(token, text, mentionCache));
403
- console.log(`── ${id} ${chLabel} [${stamp}]${sentTag}`);
481
+ console.log(`-- ${id} ${chLabel} [${stamp}]${sentTag}`);
404
482
  for (const line of resolved.split("\n")) console.log(` ${line}`);
405
483
  }
406
484
  }
@@ -450,7 +528,8 @@ function requireCode(provided: string | undefined, expected: string, contextLine
450
528
  }
451
529
 
452
530
  /** Split a target ref that may embed a message ts.
453
- * Accepts: `#chan:ts`, `@user:ts`, Slack permalink URL, or plain ref. */
531
+ * Accepts: `#chan:1700000000.000100`, `#chan:2026-05-11T06:01:04.000100`, Slack permalink URL, or plain ref.
532
+ * Throws if an ISO-format ts is present but missing the required 6-digit fractional part. */
454
533
  function splitRefTs(s: string): { ref: string; ts?: string } {
455
534
  const url = parseSlackPermalink(s);
456
535
  if (url) return url.ts ? { ref: url.channel, ts: url.ts } : { ref: url.channel };
@@ -461,6 +540,9 @@ function splitRefTs(s: string): { ref: string; ts?: string } {
461
540
  if (/^\d{10}\.\d{6}$/.test(maybeTs)) {
462
541
  return { ref: s.slice(0, colon), ts: maybeTs };
463
542
  }
543
+ if (/^\d{4}-\d{2}-\d{2}T/.test(maybeTs)) {
544
+ return { ref: s.slice(0, colon), ts: isoToSlackTs(maybeTs) };
545
+ }
464
546
  }
465
547
  }
466
548
  return { ref: s };
@@ -476,7 +558,7 @@ interface EditArgs {
476
558
  async function cmdEdit(token: string, args: EditArgs): Promise<void> {
477
559
  const { ref, ts } = splitRefTs(args.target);
478
560
  if (!ts) {
479
- console.error("Error: target must embed a message ts (e.g. #chan:1700000000.000100 or a Slack permalink URL)");
561
+ console.error("Error: target must embed a message ts (e.g. #chan:2026-05-11T06:01:04.000100 or a Slack permalink URL)");
480
562
  process.exit(2);
481
563
  }
482
564
 
@@ -497,11 +579,11 @@ async function cmdEdit(token: string, args: EditArgs): Promise<void> {
497
579
  const code = safetyCode(originalText, args.newText);
498
580
  if (args.code !== code) {
499
581
  requireCode(args.code, code, [
500
- `─── Original message ─────────────────────────`,
582
+ `--- Original message -------------------------`,
501
583
  ...originalText.split("\n").map((l) => ` ${l}`),
502
- `─── Replacing with ───────────────────────────`,
584
+ `--- Replacing with ---------------------------`,
503
585
  ...args.newText.split("\n").map((l) => ` ${l}`),
504
- `─────────────────────────────────────────────`,
586
+ `--------------------------------────────────`,
505
587
  ]);
506
588
  }
507
589
 
@@ -541,22 +623,109 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
541
623
 
542
624
  if (args.code !== code) {
543
625
  requireCode(args.code, code, [
544
- `─── Last message in channel ──────────────────`,
626
+ `--- Last message in channel ------------------`,
545
627
  ` ${lastUser}: ${lastText.split("\n")[0]?.slice(0, 100) ?? "(empty)"}`,
546
- `─── Sending ──────────────────────────────────`,
628
+ `--- Sending ----------------------------------`,
547
629
  ` To: ${args.target}${args.thread ? ` (thread ${args.thread})` : ""}`,
548
630
  ` Message: ${args.message}`,
549
- `─────────────────────────────────────────────`,
631
+ `--------------------------------────────────`,
550
632
  ]);
551
633
  }
552
634
  const ts = await slackSend(token, channelId, args.message, args.thread);
553
635
  console.log(`✓ Sent (ts: ${ts})`);
554
636
  }
555
637
 
638
+ // --- schedule ---
639
+ function parsePostAt(at: string): number {
640
+ if (/^\d{10,}$/.test(at)) return parseInt(at, 10);
641
+ const d = new Date(at.replace(" ", "T"));
642
+ if (isNaN(d.getTime())) throw new Error(`Cannot parse time: ${at}`);
643
+ return Math.floor(d.getTime() / 1000);
644
+ }
645
+
646
+ interface ScheduleSendArgs {
647
+ target: string;
648
+ message: string;
649
+ at: string;
650
+ thread?: string;
651
+ code?: string;
652
+ channelId?: string;
653
+ userId?: string;
654
+ }
655
+ async function cmdScheduleSend(token: string, args: ScheduleSendArgs): Promise<void> {
656
+ let channelId: string;
657
+ if (args.channelId) channelId = args.channelId;
658
+ else if (args.userId) channelId = await openDm(token, args.userId);
659
+ else if (args.target.startsWith("#") || args.target.startsWith("@")) {
660
+ channelId = await resolveChannel(token, args.target);
661
+ } else {
662
+ console.error(`Error: target must be #channel-name or @username (got: ${args.target})`);
663
+ process.exit(1);
664
+ }
665
+
666
+ const postAt = parsePostAt(args.at);
667
+ const postAtDate = new Date(postAt * 1000).toISOString();
668
+ const code = safetyCode(channelId, args.message, String(postAt));
669
+
670
+ if (args.code !== code) {
671
+ requireCode(args.code, code, [
672
+ `--- Scheduling message -----------------------`,
673
+ ` To: ${args.target}${args.thread ? ` (thread ${args.thread})` : ""}`,
674
+ ` At: ${postAtDate} (Unix: ${postAt})`,
675
+ ` Message: ${args.message}`,
676
+ `---------------------------------------------`,
677
+ ]);
678
+ }
679
+ const id = await scheduleMessage(token, channelId, args.message, postAt, args.thread);
680
+ console.log(`✓ Scheduled (id: ${id}, at: ${postAtDate})`);
681
+ }
682
+
683
+ async function cmdScheduleList(token: string, target?: string, channelId?: string): Promise<void> {
684
+ let channel: string | undefined;
685
+ if (channelId) {
686
+ channel = channelId;
687
+ } else if (target) {
688
+ channel = await resolveChannel(token, target);
689
+ }
690
+ const resp = (await listScheduledMessages(token, channel)) as {
691
+ scheduled_messages?: { id: string; channel_id: string; post_at: number; text: string }[];
692
+ };
693
+ const msgs = resp.scheduled_messages ?? [];
694
+ if (msgs.length === 0) { console.log("(no scheduled messages)"); return; }
695
+ for (const m of msgs) {
696
+ const at = new Date(m.post_at * 1000).toISOString();
697
+ console.log(`${m.id} ${at} [${m.channel_id}] ${m.text.split("\n")[0]?.slice(0, 80) ?? ""}`);
698
+ }
699
+ }
700
+
701
+ interface ScheduleRmArgs {
702
+ target: string;
703
+ id: string;
704
+ code?: string;
705
+ channelId?: string;
706
+ }
707
+ async function cmdScheduleRm(token: string, args: ScheduleRmArgs): Promise<void> {
708
+ let channelId: string;
709
+ if (args.channelId) channelId = args.channelId;
710
+ else channelId = await resolveChannel(token, args.target);
711
+
712
+ const code = safetyCode(channelId, args.id);
713
+ if (args.code !== code) {
714
+ requireCode(args.code, code, [
715
+ `--- Deleting scheduled message ---------------`,
716
+ ` Channel: ${args.target}`,
717
+ ` ID: ${args.id}`,
718
+ `---------------------------------------------`,
719
+ ]);
720
+ }
721
+ await deleteScheduledMessage(token, channelId, args.id);
722
+ console.log(`✓ Deleted scheduled message ${args.id}`);
723
+ }
724
+
556
725
  // --- upload ---
557
726
  interface UploadArgs {
558
727
  target: string;
559
- filePath: string;
728
+ filePaths: string[];
560
729
  title?: string;
561
730
  thread?: string;
562
731
  comment?: string;
@@ -568,11 +737,12 @@ async function cmdUpload(token: string, args: UploadArgs): Promise<void> {
568
737
  const { statSync, existsSync } = await import("node:fs");
569
738
  const { basename } = await import("node:path");
570
739
 
571
- if (!existsSync(args.filePath)) {
572
- console.error(`Error: file not found: ${args.filePath}`);
573
- process.exit(1);
740
+ for (const fp of args.filePaths) {
741
+ if (!existsSync(fp)) {
742
+ console.error(`Error: file not found: ${fp}`);
743
+ process.exit(1);
744
+ }
574
745
  }
575
- const stat = statSync(args.filePath);
576
746
 
577
747
  let channelId: string;
578
748
  if (args.channelId) channelId = args.channelId;
@@ -584,427 +754,561 @@ async function cmdUpload(token: string, args: UploadArgs): Promise<void> {
584
754
  process.exit(1);
585
755
  }
586
756
 
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`;
757
+ function fmtSize(n: number): string {
758
+ return n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`;
759
+ }
760
+
761
+ const isBatch = args.filePaths.length > 1;
762
+ const files = args.filePaths.map((fp) => {
763
+ const filename = basename(fp);
764
+ const title = isBatch ? filename : (args.title ?? filename);
765
+ return { fp, filename, title, sizeFmt: fmtSize(statSync(fp).size) };
766
+ });
594
767
 
595
- const code = safetyCode(channelId, args.filePath, title);
768
+ // Safety code covers the full batch — single-file code is identical to the old formula.
769
+ const code = safetyCode(channelId, ...files.flatMap((f) => [f.fp, f.title]));
596
770
  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
- ]);
771
+ const destLine = ` To: ${args.target}${args.thread ? ` (thread ${args.thread})` : ""}`;
772
+ const lines = isBatch
773
+ ? [
774
+ `--- Uploading ${files.length} files ------------------------`,
775
+ destLine,
776
+ ...files.map((f) => ` ${f.filename} (${f.sizeFmt})`),
777
+ `--------------------------------────────────`,
778
+ ]
779
+ : [
780
+ `--- Uploading file ---------------------------`,
781
+ destLine,
782
+ ` File: ${files[0]!.fp}`,
783
+ ` Title: ${files[0]!.title}`,
784
+ ` Size: ${files[0]!.sizeFmt}`,
785
+ `--------------------------------────────────`,
786
+ ];
787
+ requireCode(args.code, code, lines);
605
788
  }
606
789
 
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}` : ""})`);
790
+ const total = files.length;
791
+ for (let i = 0; i < total; i++) {
792
+ const f = files[i]!;
793
+ const uploadOpts: { title?: string; threadTs?: string; initialComment?: string } = { title: f.title };
794
+ if (args.thread !== undefined) uploadOpts.threadTs = args.thread;
795
+ if (args.comment !== undefined && i === 0) uploadOpts.initialComment = args.comment;
796
+ const { fileId, permalink } = await uploadFile(token, channelId, f.fp, uploadOpts);
797
+ const prefix = total > 1 ? `[${i + 1}/${total}] ` : "";
798
+ console.log(`${prefix}✓ Uploaded (file_id: ${fileId}${permalink ? `, url: ${permalink}` : ""})`);
799
+ }
612
800
  }
613
801
 
614
802
  // --- 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
803
 
765
804
  async function main(): Promise<void> {
766
805
  loadDotenvFiles();
767
806
 
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);
807
+ type W = { workspace?: string };
808
+ const tok = (a: W) => resolveToken(a.workspace);
809
+ const ck = (a: W) => resolveCookie(a.workspace);
786
810
 
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
- ]);
811
+ await yargs(hideBin(process.argv))
812
+ .scriptName("slack")
813
+ .option("workspace", { alias: "w", type: "string", describe: "Workspace name" })
814
+ .middleware(async (argv) => {
815
+ const cmd = String((argv._ ?? [])[0] ?? "");
816
+ if (!cmd || cmd === "auth" || cmd === "login") return;
817
+ try {
818
+ resolveToken((argv as W).workspace);
819
+ } catch (e) {
820
+ const msg = e instanceof Error ? e.message : String(e);
821
+ if (msg.startsWith("No profiles configured")) {
822
+ console.log("No workspace configured. Let's set that up:\n");
823
+ await cmdAuthLogin();
824
+ process.exit(0);
878
825
  }
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);
826
+ throw e;
904
827
  }
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,
828
+ }, true)
829
+ .command(
830
+ ["read [target]", "msgs [target]"],
831
+ "Browse messages",
832
+ (y) => y
833
+ .positional("target", { type: "string", describe: "#channel, @user, or URL" })
834
+ .option("limit", { alias: "n", type: "number", default: 20, describe: "Number of messages" }),
835
+ async (argv) => {
836
+ const token = tok(argv as W);
837
+ if (argv.target) await cmdMsgsTarget(token, argv.target, argv.limit);
838
+ else await cmdMsgs(token);
839
+ },
840
+ )
841
+ .command(
842
+ "thread <target> <ts>",
843
+ "Show thread messages",
844
+ (y) => y
845
+ .positional("target", { type: "string", demandOption: true })
846
+ .positional("ts", { type: "string", demandOption: true })
847
+ .option("limit", { alias: "n", type: "number", default: 100 }),
848
+ async (argv) => {
849
+ await cmdThread(tok(argv as W), argv.target!, argv.ts!, argv.limit);
850
+ },
851
+ )
852
+ .command(
853
+ ["channel", "channels", "ch"],
854
+ "Channel commands",
855
+ (y) => y
856
+ .command(
857
+ ["ls", "list"],
858
+ "List channels",
859
+ (y2) => y2
860
+ .option("limit", { alias: "n", type: "number", default: 200 })
861
+ .option("filter", { alias: "f", type: "string" })
862
+ .option("all", { type: "boolean", default: false })
863
+ .option("format", { type: "string", choices: ["text", "jsonl"] as const, default: "text" })
864
+ .option("json", { type: "boolean", default: false, describe: "Alias for --format=jsonl" }),
865
+ async (argv) => {
866
+ await cmdChannels(tok(argv as W), argv.limit, argv.filter, argv.all, argv.json ? "jsonl" : argv.format);
867
+ },
868
+ )
869
+ .command(
870
+ "get <channel>",
871
+ "Show channel details",
872
+ (y2) => y2.positional("channel", { type: "string", demandOption: true, describe: "#name or channel ID" }),
873
+ async (argv) => {
874
+ const token = tok(argv as W);
875
+ const ref = argv.channel!;
876
+ const channelRef = ref.startsWith("#") || ref.startsWith("@") || ref.startsWith("C") || ref.startsWith("G") || ref.startsWith("D") ? ref : `#${ref}`;
877
+ const channelId = await resolveChannel(token, channelRef);
878
+ const resp = asRecord((await conversationInfo(token, channelId)) as Json);
879
+ const ch = asRecord(resp.channel);
880
+ const name = typeof ch.name === "string" ? ch.name : channelId;
881
+ const isIm = ch.is_im === true;
882
+ const prefix = isIm ? "@" : "#";
883
+ const purpose = String(asRecord(ch.purpose).value ?? "");
884
+ const topic = String(asRecord(ch.topic).value ?? "");
885
+ const memberCount = ch.num_members ?? ch.member_count ?? "";
886
+ console.log(`${prefix}${name} ${channelId}`);
887
+ if (topic) console.log(`topic: ${topic}`);
888
+ if (purpose) console.log(`purpose: ${purpose}`);
889
+ if (memberCount) console.log(`members: ${memberCount}`);
890
+ console.log(`private: ${ch.is_private === true}`);
891
+ },
892
+ )
893
+ .demandCommand(1, "")
894
+ .showHelpOnFail(true),
895
+ )
896
+ .command(
897
+ ["user", "usr"],
898
+ "User commands",
899
+ (y) => y
900
+ .command(
901
+ ["ls", "list"],
902
+ "List workspace members",
903
+ (y2) => y2
904
+ .option("limit", { alias: "n", type: "number", default: 200 })
905
+ .option("filter", { alias: "f", type: "string" })
906
+ .option("format", { type: "string", choices: ["text", "jsonl", "yaml"] as const, default: "text" })
907
+ .option("json", { type: "boolean", default: false, describe: "Alias for --format=jsonl" }),
908
+ async (argv) => {
909
+ const format = argv.json ? "jsonl" : argv.format;
910
+ const resp = (await listUsers(tok(argv as W))) as Record<string, Json>;
911
+ const filter = argv.filter?.toLowerCase();
912
+ const members = asArray(resp.members)
913
+ .map(asRecord)
914
+ .filter((u) => u.deleted !== true && u.is_bot !== true && String(u.id) !== "USLACKBOT")
915
+ .filter((u) => {
916
+ if (!filter) return true;
917
+ const name = String(u.name ?? "").toLowerCase();
918
+ const real = String(asRecord(u.profile).real_name ?? "").toLowerCase();
919
+ const email = String(asRecord(u.profile).email ?? "").toLowerCase();
920
+ return name.includes(filter) || real.includes(filter) || email.includes(filter);
921
+ })
922
+ .slice(0, argv.limit);
923
+ if (format === "jsonl") {
924
+ for (const u of members) console.log(JSON.stringify(u));
925
+ return;
926
+ }
927
+ if (format === "yaml") {
928
+ for (const u of members) {
929
+ console.log("---");
930
+ function yamlVal(v: Json, indent = ""): string {
931
+ if (v === null) return "null";
932
+ if (typeof v === "string") return v.includes("\n") ? `|\n ${v.split("\n").join("\n ")}` : v;
933
+ if (typeof v !== "object") return String(v);
934
+ if (Array.isArray(v)) return v.map((i) => `\n${indent} - ${yamlVal(i, indent + " ")}`).join("");
935
+ return Object.entries(v as Record<string, Json>).map(([k, val]) =>
936
+ `\n${indent} ${k}: ${yamlVal(val, indent + " ")}`).join("");
937
+ }
938
+ for (const [k, v] of Object.entries(u)) console.log(`${k}: ${yamlVal(v)}`);
939
+ }
940
+ return;
941
+ }
942
+ const localTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
943
+ for (const u of members) {
944
+ const profile = asRecord(u.profile);
945
+ const handle = String(u.name ?? u.id);
946
+ const id = String(u.id ?? "");
947
+ const display = String(profile.display_name || "");
948
+ const real = String(profile.real_name || "");
949
+ const email = String(profile.email || "");
950
+ const tz = String(u.tz ?? "");
951
+ const names = [...new Set([display, real].filter((s) => s && s !== handle))];
952
+ const parts = names.join(" / ");
953
+ const tzPart = tz && tz !== localTz ? tz : "";
954
+ const meta = [email, tzPart].filter(Boolean).join(" ");
955
+ console.log(`@${handle} ${id} ${parts}${meta ? " " + meta : ""}`);
956
+ }
957
+ },
958
+ )
959
+ .command(
960
+ "get <user>",
961
+ "Show user details",
962
+ (y2) => y2.positional("user", { type: "string", demandOption: true, describe: "@handle or user ID" }),
963
+ async (argv) => {
964
+ const token = tok(argv as W);
965
+ const ref = argv.user!.startsWith("@") ? argv.user!.slice(1) : argv.user!;
966
+ // If not a Slack user ID (U + alphanumeric), resolve from users list by handle
967
+ let userId = ref;
968
+ if (!/^U[A-Z0-9]+$/.test(ref)) {
969
+ const listResp = (await listUsers(token)) as Record<string, Json>;
970
+ const match = asArray(listResp.members).map(asRecord)
971
+ .find((u) => String(u.name ?? "") === ref);
972
+ if (!match) { console.error(`User not found: @${ref}`); process.exit(1); }
973
+ userId = String(match.id);
974
+ }
975
+ const resp = asRecord((await userInfo(token, userId)) as Json);
976
+ const u = asRecord(resp.user);
977
+ const profile = asRecord(u.profile);
978
+ const localTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
979
+ const handle = String(u.name ?? userId);
980
+ const id = String(u.id ?? userId);
981
+ const display = String(profile.display_name || "");
982
+ const real = String(profile.real_name || "");
983
+ const email = String(profile.email || "");
984
+ const tz = String(u.tz ?? "");
985
+ const phone = String(profile.phone || "");
986
+ const title = String(profile.title || "");
987
+ console.log(`@${handle} ${id}`);
988
+ const names = [...new Set([display, real].filter((s) => s && s !== handle))];
989
+ if (names.length) console.log(`name: ${names.join(" / ")}`);
990
+ if (email) console.log(`email: ${email}`);
991
+ if (phone) console.log(`phone: ${phone}`);
992
+ if (title) console.log(`title: ${title}`);
993
+ if (tz && tz !== localTz) console.log(`tz: ${tz}`);
994
+ },
995
+ )
996
+ .demandCommand(1, "")
997
+ .showHelpOnFail(true),
998
+ )
999
+ .command(
1000
+ "news",
1001
+ "Activity feed (mentions to you)",
1002
+ (y) => y.option("limit", { alias: "l", type: "number", default: 20 }),
1003
+ async (argv) => {
1004
+ await cmdNews(tok(argv as W), argv.limit);
1005
+ },
1006
+ )
1007
+ .command(
1008
+ "search <query>",
1009
+ "Full-text search",
1010
+ (y) => y
1011
+ .positional("query", { type: "string", demandOption: true })
1012
+ .option("count", { alias: "n", type: "number", default: 100 })
1013
+ .option("json", { type: "boolean", default: false, describe: "Output raw JSON" }),
1014
+ async (argv) => {
1015
+ await cmdSearch(tok(argv as W), argv.query!, argv.count, argv.json);
1016
+ },
1017
+ )
1018
+ .command(
1019
+ "send <target> <message>",
1020
+ "Send a message (confirm-hash safety gate)",
1021
+ (y) => y
1022
+ .positional("target", { type: "string", demandOption: true })
1023
+ .positional("message", { type: "string", demandOption: true })
1024
+ .option("thread", { type: "string", describe: "Thread timestamp" })
1025
+ .option("code", { type: "string", describe: "Safety hash to confirm send" })
1026
+ .option("channel-id", { type: "string", describe: "Raw channel ID" })
1027
+ .option("user-id", { type: "string", describe: "Raw user ID (opens DM)" }),
1028
+ async (argv) => {
1029
+ const args: SendArgs = { target: argv.target!, message: argv.message! };
1030
+ if (argv.thread) args.thread = argv.thread;
1031
+ if (argv.code) args.code = argv.code;
1032
+ if (argv["channel-id"]) args.channelId = argv["channel-id"];
1033
+ if (argv["user-id"]) args.userId = argv["user-id"];
1034
+ await cmdSend(tok(argv as W), args);
1035
+ },
1036
+ )
1037
+ .command(
1038
+ "schedule",
1039
+ "Manage scheduled messages",
1040
+ (y) => y
1041
+ .command(
1042
+ "send <target> <message>",
1043
+ "Schedule a message for later delivery",
1044
+ (y2) => y2
1045
+ .positional("target", { type: "string", demandOption: true })
1046
+ .positional("message", { type: "string", demandOption: true })
1047
+ .option("at", { type: "string", demandOption: true, describe: "Delivery time (ISO datetime or Unix ts)" })
1048
+ .option("thread", { type: "string", describe: "Thread timestamp" })
1049
+ .option("code", { type: "string", describe: "Safety hash to confirm" })
1050
+ .option("channel-id", { type: "string", describe: "Raw channel ID" })
1051
+ .option("user-id", { type: "string", describe: "Raw user ID (opens DM)" }),
1052
+ async (argv) => {
1053
+ const args: ScheduleSendArgs = { target: argv.target!, message: argv.message!, at: argv.at! };
1054
+ if (argv.thread) args.thread = argv.thread;
1055
+ if (argv.code) args.code = argv.code;
1056
+ if (argv["channel-id"]) args.channelId = argv["channel-id"];
1057
+ if (argv["user-id"]) args.userId = argv["user-id"];
1058
+ await cmdScheduleSend(tok(argv as W), args);
1059
+ },
1060
+ )
1061
+ .command(
1062
+ ["ls", "list"],
1063
+ "List pending scheduled messages",
1064
+ (y2) => y2
1065
+ .positional("target", { type: "string", describe: "#channel to filter by" })
1066
+ .option("channel-id", { type: "string", describe: "Raw channel ID" }),
1067
+ async (argv) => {
1068
+ await cmdScheduleList(tok(argv as W), argv.target as string | undefined, argv["channel-id"]);
1069
+ },
1070
+ )
1071
+ .command(
1072
+ "rm <target> <id>",
1073
+ "Delete a scheduled message",
1074
+ (y2) => y2
1075
+ .positional("target", { type: "string", demandOption: true, describe: "#channel or @user" })
1076
+ .positional("id", { type: "string", demandOption: true, describe: "Scheduled message ID" })
1077
+ .option("code", { type: "string", describe: "Safety hash to confirm" })
1078
+ .option("channel-id", { type: "string", describe: "Raw channel ID" }),
1079
+ async (argv) => {
1080
+ const args: ScheduleRmArgs = { target: argv.target!, id: argv.id! };
1081
+ if (argv.code) args.code = argv.code;
1082
+ if (argv["channel-id"]) args.channelId = argv["channel-id"];
1083
+ await cmdScheduleRm(tok(argv as W), args);
1084
+ },
1085
+ )
1086
+ .demandCommand(1, "")
1087
+ .showHelpOnFail(true),
1088
+ () => {},
1089
+ )
1090
+ .command(
1091
+ "edit <target> <newText>",
1092
+ "Edit a sent message",
1093
+ (y) => y
1094
+ .positional("target", { type: "string", demandOption: true, describe: "#chan:ts, @user:ts, or permalink" })
1095
+ .positional("newText", { type: "string", demandOption: true })
1096
+ .option("code", { type: "string", describe: "Safety hash to confirm edit" })
1097
+ .option("channel-id", { type: "string", describe: "Raw channel ID" }),
1098
+ async (argv) => {
1099
+ const args: EditArgs = { target: argv.target!, newText: argv.newText! };
1100
+ if (argv.code) args.code = argv.code;
1101
+ if (argv["channel-id"]) args.channelId = argv["channel-id"];
1102
+ await cmdEdit(tok(argv as W), args);
1103
+ },
1104
+ )
1105
+ .command(
1106
+ "upload <target> <file..>",
1107
+ "Upload one or more files to a channel or DM",
1108
+ (y) => y
1109
+ .positional("target", { type: "string", demandOption: true })
1110
+ .positional("file", { type: "string", array: true, demandOption: true, describe: "Path(s) to file(s)" })
1111
+ .option("title", { type: "string", describe: "Title (single file only)" })
1112
+ .option("thread", { type: "string", describe: "Thread timestamp" })
1113
+ .option("comment", { type: "string", describe: "Initial comment (first file)" })
1114
+ .option("code", { type: "string", describe: "4-hex safety code to confirm upload" })
1115
+ .option("channel-id", { type: "string" })
1116
+ .option("user-id", { type: "string" }),
1117
+ async (argv) => {
1118
+ const args: UploadArgs = { target: argv.target!, filePaths: argv.file as string[] };
1119
+ if (argv.title) args.title = argv.title;
1120
+ if (argv.thread) args.thread = argv.thread;
1121
+ if (argv.comment) args.comment = argv.comment;
1122
+ if (argv.code) args.code = argv.code;
1123
+ if (argv["channel-id"]) args.channelId = argv["channel-id"];
1124
+ if (argv["user-id"]) args.userId = argv["user-id"];
1125
+ await cmdUpload(tok(argv as W), args);
1126
+ },
1127
+ )
1128
+ .command(
1129
+ "dump",
1130
+ "Bulk export channel history as markdown",
1131
+ (y) => y
1132
+ .option("days", { alias: "d", type: "number", default: 7, describe: "Days of history" })
1133
+ .option("limit", { alias: "l", type: "number", default: 200 })
1134
+ .option("filter", { alias: "f", type: "string", describe: "Filter channel names" }),
1135
+ async (argv) => {
1136
+ await cmdDump(tok(argv as W), argv.days, argv.limit, argv.filter);
1137
+ },
1138
+ )
1139
+ .command(
1140
+ "drafts",
1141
+ "Manage message drafts (requires xoxc desktop token)",
1142
+ (y) => y
1143
+ .command(
1144
+ ["$0", "ls", "list"],
1145
+ "List pending drafts",
1146
+ (y2) => y2.option("all", { alias: "a", type: "boolean", default: false, describe: "Include sent drafts" }),
1147
+ async (argv) => {
1148
+ await cmdDrafts(tok(argv as W), ck(argv as W), argv.all);
1149
+ },
1150
+ )
1151
+ .command(
1152
+ ["new <channel> [text..]", "save <channel> [text..]"],
1153
+ "Create a draft",
1154
+ (y2) => y2
1155
+ .positional("channel", { type: "string", demandOption: true })
1156
+ .positional("text", { type: "string", array: true, default: [] }),
1157
+ async (argv) => {
1158
+ const token = tok(argv as W); const cookie = ck(argv as W);
1159
+ const text = (argv.text as string[]).join(" ");
1160
+ if (!text) { console.error("Usage: slack drafts new <#channel|@user> <text>"); process.exit(2); }
1161
+ const channelId = await resolveChannel(token, argv.channel!, cookie);
1162
+ const resp = (await createDraft(token, channelId, text, cookie)) as Record<string, Json>;
1163
+ console.log(`✓ Draft created (id: ${asRecord(resp.draft).id ?? "?"})`);
1164
+ },
1165
+ )
1166
+ .command(
1167
+ "get <id>",
1168
+ "Show a draft",
1169
+ (y2) => y2.positional("id", { type: "string", demandOption: true }),
1170
+ async (argv) => {
1171
+ await cmdDraftGet(tok(argv as W), ck(argv as W), argv.id!);
1172
+ },
1173
+ )
1174
+ .command(
1175
+ ["edit <id> [text..]", "update <id> [text..]"],
1176
+ "Edit a draft",
1177
+ (y2) => y2
1178
+ .positional("id", { type: "string", demandOption: true })
1179
+ .positional("text", { type: "string", array: true, default: [] })
1180
+ .option("code", { type: "string" }),
1181
+ async (argv) => {
1182
+ const token = tok(argv as W); const cookie = ck(argv as W);
1183
+ const text = (argv.text as string[]).join(" ");
1184
+ if (!text) { console.error("Usage: slack drafts edit <id> <new-text>"); process.exit(2); }
1185
+ const listResp = (await listDrafts(token, cookie)) as Record<string, Json>;
1186
+ const d = asArray(listResp.drafts).map(asRecord).find((x) => String(x.id) === argv.id);
1187
+ if (!d) { console.error(`Draft not found: ${argv.id}`); process.exit(1); }
1188
+ const prevText = draftText(d);
1189
+ const code = safetyCode(prevText, text);
1190
+ if (argv.code !== code) requireCode(argv.code, code, [
1191
+ `--- Current draft ----------------------------`,
1192
+ ...prevText.split("\n").map((l) => ` ${l}`),
1193
+ `--- Replacing with ---------------------------`,
1194
+ ...text.split("\n").map((l) => ` ${l}`),
1195
+ `--------------------------------────────────`,
1196
+ ]);
1197
+ const resp = (await updateDraft(token, argv.id!, draftChannelId(d), text, cookie)) as Record<string, Json>;
1198
+ console.log(`✓ Draft updated (id: ${asRecord(resp.draft).id ?? "?"})`);
1199
+ },
1200
+ )
1201
+ .command(
1202
+ ["delete <id>", "rm <id>"],
1203
+ "Delete a draft",
1204
+ (y2) => y2
1205
+ .positional("id", { type: "string", demandOption: true })
1206
+ .option("code", { type: "string" }),
1207
+ async (argv) => {
1208
+ const token = tok(argv as W); const cookie = ck(argv as W);
1209
+ const listResp = (await listDrafts(token, cookie)) as Record<string, Json>;
1210
+ const d = asArray(listResp.drafts).map(asRecord).find((x) => String(x.id) === argv.id);
1211
+ if (!d) { console.error(`Draft not found: ${argv.id}`); process.exit(1); }
1212
+ const prevText = draftText(d);
1213
+ const code = safetyCode(argv.id!, prevText);
1214
+ if (argv.code !== code) requireCode(argv.code, code, [
1215
+ `─-- Deleting draft ───────────────────────────`,
1216
+ ` id: ${argv.id}`,
1217
+ ...prevText.split("\n").map((l) => ` ${l}`),
1218
+ `--------------------------------────────────`,
1219
+ ]);
1220
+ await deleteDraft(token, argv.id!, cookie);
1221
+ console.log(`✓ Draft deleted (id: ${argv.id})`);
1222
+ },
1223
+ ),
1224
+ )
1225
+ .command(
1226
+ "auth",
1227
+ "Authentication and workspace management",
1228
+ (y) => y
1229
+ .command(
1230
+ "login",
1231
+ "Add a workspace (interactive or --token for non-interactive)",
1232
+ (y2) => y2
1233
+ .option("token", { type: "string", describe: "Token to save directly (non-interactive)" })
1234
+ .option("name", { type: "string", describe: "Workspace name (used with --token)" }),
1235
+ async (argv) => {
1236
+ await cmdAuthLogin({
1237
+ ...(argv.token !== undefined ? { token: argv.token } : {}),
1238
+ ...(argv.name !== undefined ? { name: argv.name } : {}),
1239
+ });
1240
+ },
1241
+ )
1242
+ .command(["ls", "status"], "Show auth status", () => {}, () => {
1243
+ const profiles = listProfiles();
1244
+ if (profiles.length === 0) { console.log("No workspaces configured. Run: slack auth login"); return; }
1245
+ for (const { name, profile, current } of profiles)
1246
+ console.log(`${current ? "* " : " "}${name} ${profile.team} (${profile.user}) ${profile.url ?? ""}`);
1247
+ })
1248
+ .command(
1249
+ "use <name>",
1250
+ "Switch active workspace",
1251
+ (y2) => y2
1252
+ .positional("name", { type: "string", demandOption: true })
1253
+ .option("g", { type: "boolean", default: false, describe: "Write global lockfile (~/.slack-cli/workspace)" }),
1254
+ (argv) => {
1255
+ if (!argv.g) ensureSlackCliDir(join(process.cwd(), ".slack-cli"));
1256
+ useProfile(argv.name!, argv.g);
1257
+ console.log(`Switched to workspace "${argv.name}" ${argv.g ? "globally" : "locally"}`);
1258
+ },
1259
+ )
1260
+ .command(
1261
+ ["rm <name>", "remove <name>"],
1262
+ "Remove a workspace",
1263
+ (y2) => y2.positional("name", { type: "string", demandOption: true }),
1264
+ (argv) => {
1265
+ removeProfile(argv.name!);
1266
+ console.log(`Removed workspace "${argv.name}"`);
1267
+ },
1268
+ )
1269
+ .command("$0", false as unknown as string, () => {}, () => { y.showHelp(); process.exit(0); }),
1270
+ )
1271
+ .command(
1272
+ "tail [target]",
1273
+ "Stream new messages in real time (like tail -f)",
1274
+ (y) => y
1275
+ .positional("target", { type: "string", describe: "#channel or @user to follow" })
1276
+ .option("since", { type: "string", describe: "Backfill from N ago (e.g. 10m, 2h, 1d)" })
1277
+ .option("thread", { type: "string", describe: "Follow a single thread by timestamp" })
1278
+ .option("me", { type: "boolean", default: false, describe: "Filter to messages that mention you" })
1279
+ .option("interval", { type: "number", default: 60000, describe: "Poll interval in ms (default 60s; use --interval=3000 for near-real-time)" })
1280
+ .option("rtm", { type: "boolean", default: true, describe: "Use RTM WebSocket when available (xoxc + cookie); pass --no-rtm to force polling" }),
1281
+ async (argv) => {
1282
+ const token = tok(argv as W);
1283
+ const cookie = ck(argv as W);
1284
+ const signal = new AbortController();
1285
+ process.on("SIGINT", () => { signal.abort(); process.exit(0); });
1286
+ await cmdTail(token, argv.target, {
1287
+ ...(argv.since !== undefined ? { since: argv.since } : {}),
1288
+ ...(argv.thread !== undefined ? { thread: argv.thread } : {}),
1289
+ me: argv.me,
1290
+ interval: argv.interval,
1291
+ ...(cookie !== undefined ? { cookie } : {}),
1292
+ ...(argv.rtm === false ? { noRtm: true } : {}),
1293
+ }, signal.signal);
1294
+ },
1295
+ )
1296
+ .command("login", false as unknown as string, (y2) => y2
1297
+ .option("token", { type: "string" })
1298
+ .option("name", { type: "string" }), async (argv) => {
1299
+ await cmdAuthLogin({
1300
+ ...(argv.token !== undefined ? { token: argv.token } : {}),
1301
+ ...(argv.name !== undefined ? { name: argv.name } : {}),
930
1302
  });
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
- }
1303
+ })
1304
+ .demandCommand(1, "Specify a command. Run with --help for usage.")
1305
+ .strict()
1306
+ .help()
1307
+ .alias("help", "h")
1308
+ .parseAsync();
1004
1309
  }
1005
1310
 
1006
1311
  main().catch((e: unknown) => {
1007
- const msg = e instanceof Error ? e.message : String(e);
1008
- console.error(`Error: ${msg}`);
1312
+ console.error(e instanceof Error ? e.message : String(e));
1009
1313
  process.exit(1);
1010
1314
  });