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