slack-term 1.14.0 → 1.16.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
@@ -8,17 +8,20 @@ import { join } from "node:path";
8
8
 
9
9
  import yargs from "yargs";
10
10
  import { hideBin } from "yargs/helpers";
11
- import { listProfiles, removeProfile, resolveCookie, resolveToken, useProfile } from "./profiles.ts";
12
- import { cmdAuthLogin } from "./auth.ts";
11
+ import { listProfiles, removeProfile, resolveBotToken, resolveCookie, resolveToken, useProfile } from "./profiles.ts";
12
+ import { diagnoseBotMessaging, formatDiagnosis } from "./botdoctor.ts";
13
+ import { cmdAuthLogin, cmdAuthChrome, cmdAuthFirefox, cmdAuthToken, cmdAuthApp } from "./auth.ts";
13
14
  import { cmdTail } from "./tail.ts";
14
15
 
15
16
  import {
17
+ authTest,
16
18
  authTestSession,
17
19
  conversationInfoSession,
18
20
  createDraft,
19
21
  deleteDraft,
20
22
  updateDraft,
21
23
  editMessage,
24
+ deleteMessage,
22
25
  history,
23
26
  listConversations,
24
27
  listDrafts,
@@ -29,9 +32,11 @@ import {
29
32
  parseSlackPermalink,
30
33
  replies,
31
34
  resolveChannel,
35
+ resolveUserId,
32
36
  search,
33
37
  searchAll,
34
38
  send as slackSend,
39
+ getPermalink,
35
40
  scheduleMessage,
36
41
  listScheduledMessages,
37
42
  deleteScheduledMessage,
@@ -170,34 +175,65 @@ async function formatMsgLine(
170
175
  const lines = resolved.split("\n");
171
176
  const body = lines[0] + (lines.length > 1 ? "\n" + lines.slice(1).map(l => ` ${l}`).join("\n") : "");
172
177
  const who = chLabel ? `${chLabel} @${handle}` : `@${handle}`;
173
- return `${stamp} ${who}: ${body}`;
178
+ const reactions = asArray(m.reactions)
179
+ .map(asRecord)
180
+ .map((r) => {
181
+ const name = typeof r.name === "string" ? r.name : "";
182
+ const count = Number(r.count ?? asArray(r.users).length ?? 0);
183
+ return name ? `:${name}:×${count}` : "";
184
+ })
185
+ .filter(Boolean)
186
+ .join(" ");
187
+ const tail = reactions ? `\n ${reactions}` : "";
188
+ return `${stamp} ${who}: ${body}${tail}`;
189
+ }
190
+
191
+ // Slim a raw message down to the fields a script actually needs. Keeps the
192
+ // author's user ID (incl. external / Slack-Connect guests that never appear in
193
+ // users.list) so callers can resolve mentions without scraping history by hand.
194
+ function slimMsg(m: Record<string, Json>): Record<string, Json> {
195
+ return {
196
+ ts: m.ts ?? null,
197
+ user: m.user ?? null,
198
+ username: m.username ?? null,
199
+ bot_id: m.bot_id ?? null,
200
+ thread_ts: m.thread_ts ?? null,
201
+ text: typeof m.text === "string" ? m.text : "",
202
+ };
174
203
  }
175
204
 
176
205
  // --- msgs <target> — channel/DM history with timestamps ---
177
- async function cmdMsgsTarget(token: string, target: string, limit: number): Promise<void> {
206
+ async function cmdMsgsTarget(token: string, target: string, limit: number, format: "text" | "jsonl" = "text"): Promise<void> {
178
207
  const parsed = parseSlackPermalink(target);
179
208
  const channelId = await resolveChannel(token, target);
180
209
  const cache = new Map<string, string>();
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));
210
+ const fetchMsgs = async (): Promise<Record<string, Json>[]> => {
211
+ if (parsed?.threadTs) {
212
+ const resp = (await replies(token, channelId, parsed.threadTs, limit)) as Record<string, Json>;
213
+ return asArray(resp.messages).map(asRecord);
186
214
  }
187
- } else {
188
215
  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
- }
216
+ return asArray(hist.messages).map(asRecord).reverse();
217
+ };
218
+ const msgs = await fetchMsgs();
219
+ if (format === "jsonl") {
220
+ for (const m of msgs) console.log(JSON.stringify(slimMsg(m)));
221
+ return;
222
+ }
223
+ for (const m of msgs) {
224
+ console.log(await formatMsgLine(token, m, cache));
193
225
  }
194
226
  }
195
227
 
196
228
  // --- thread ---
197
- async function cmdThread(token: string, target: string, ts: string, limit: number): Promise<void> {
229
+ async function cmdThread(token: string, target: string, ts: string, limit: number, format: "text" | "jsonl" = "text"): Promise<void> {
198
230
  const channelId = await resolveChannel(token, target);
199
231
  const resp = (await replies(token, channelId, parseInputTs(ts), limit)) as Record<string, Json>;
200
232
  const msgs = asArray(resp.messages).map(asRecord);
233
+ if (format === "jsonl") {
234
+ for (const m of msgs) console.log(JSON.stringify(slimMsg(m)));
235
+ return;
236
+ }
201
237
  const cache = new Map<string, string>();
202
238
  for (const m of msgs) {
203
239
  console.log(await formatMsgLine(token, m, cache));
@@ -548,6 +584,69 @@ function splitRefTs(s: string): { ref: string; ts?: string } {
548
584
  return { ref: s };
549
585
  }
550
586
 
587
+ /** Parse a send/upload target that may embed a thread_ts after `:`.
588
+ * Accepts: `#chan:1700000000.000100`, `@user:ts`, `RAWID:ts`, Slack permalink, or plain ref.
589
+ * A message permalink targets that message's thread — `?thread_ts=` (the parent) wins when
590
+ * present, otherwise the message's own ts. A channel-only permalink stays top-level. */
591
+ function parseTargetThread(s: string): { ref: string; threadTs?: string } {
592
+ const url = parseSlackPermalink(s);
593
+ if (url) {
594
+ const threadTs = url.threadTs ?? url.ts;
595
+ return threadTs ? { ref: url.channel, threadTs } : { ref: url.channel };
596
+ }
597
+ const colon = s.indexOf(":");
598
+ if (colon > 0) {
599
+ const maybeTs = s.slice(colon + 1);
600
+ if (/^\d{10}\.\d{6}$/.test(maybeTs)) return { ref: s.slice(0, colon), threadTs: maybeTs };
601
+ if (/^\d{4}-\d{2}-\d{2}T/.test(maybeTs)) return { ref: s.slice(0, colon), threadTs: isoToSlackTs(maybeTs) };
602
+ }
603
+ return { ref: s };
604
+ }
605
+
606
+ /** Human-readable destination label for confirm gates, e.g. "#symval (C0B0L6SSAMD)".
607
+ * When ref is already a #channel/@user label, keep it; otherwise resolve the channel
608
+ * name via conversations.info (fail-soft: a raw ID is still unambiguous). */
609
+ async function destLabel(token: string, channelId: string, ref: string): Promise<string> {
610
+ let name = ref;
611
+ if (!ref.startsWith("#") && !ref.startsWith("@")) {
612
+ try {
613
+ const info = asRecord((await conversationInfo(token, channelId)) as Json);
614
+ const ch = asRecord(info.channel);
615
+ if (ch.is_im === true) {
616
+ const uid = typeof ch.user === "string" ? ch.user : "";
617
+ name = uid ? `@${await userName(token, uid)}` : channelId;
618
+ } else {
619
+ name = typeof ch.name === "string" ? `#${ch.name}` : channelId;
620
+ }
621
+ } catch {
622
+ name = channelId;
623
+ }
624
+ }
625
+ return name === channelId ? channelId : `${name} (${channelId})`;
626
+ }
627
+
628
+ /** One-line thread-parent description for confirm gates: "2026-06-11 08:05 @handle: head…".
629
+ * Fail-soft: returns the raw ts if the parent cannot be fetched. */
630
+ async function threadParentLine(token: string, channelId: string, threadTs: string): Promise<string> {
631
+ try {
632
+ const resp = (await replies(token, channelId, threadTs, 1)) as Record<string, Json>;
633
+ const msgs = asArray(resp.messages).map(asRecord);
634
+ const parent = msgs.find((m) => String(m.ts) === threadTs) ?? msgs[0];
635
+ if (!parent) return threadTs;
636
+ const author = typeof parent.user === "string"
637
+ ? await userName(token, parent.user)
638
+ : typeof parent.username === "string"
639
+ ? parent.username
640
+ : "?";
641
+ const head = (typeof parent.text === "string" ? parent.text : "").split("\n")[0] ?? "";
642
+ const headShort = head.length > 40 ? `${head.slice(0, 40)}…` : head;
643
+ const stamp = slackTsToIso(threadTs).slice(0, 16).replace("T", " ");
644
+ return `${stamp} @${author}${headShort ? `: ${headShort}` : ""}`;
645
+ } catch {
646
+ return threadTs;
647
+ }
648
+ }
649
+
551
650
  // --- edit ---
552
651
  interface EditArgs {
553
652
  target: string;
@@ -591,48 +690,172 @@ async function cmdEdit(token: string, args: EditArgs): Promise<void> {
591
690
  console.log(`✓ Edited (ts: ${newTs})`);
592
691
  }
593
692
 
693
+ // --- delete ---
694
+ interface DeleteArgs {
695
+ target: string;
696
+ code?: string;
697
+ channelId?: string;
698
+ }
699
+ async function cmdDelete(token: string, args: DeleteArgs): Promise<void> {
700
+ const { ref, ts } = splitRefTs(args.target);
701
+ if (!ts) {
702
+ console.error("Error: target must embed a message ts (e.g. #chan:2026-05-11T06:01:04.000100 or a Slack permalink URL)");
703
+ process.exit(2);
704
+ }
705
+
706
+ let channelId: string;
707
+ if (args.channelId) channelId = args.channelId;
708
+ else channelId = await resolveChannel(token, ref);
709
+
710
+ // Fetch the message to display what is being deleted and compute the safety hash.
711
+ const resp = (await replies(token, channelId, ts, 1)) as Record<string, Json>;
712
+ const msgs = asArray(resp.messages).map(asRecord);
713
+ const original = msgs.find((m) => String(m.ts) === ts);
714
+ if (!original) {
715
+ console.error(`Message not found at ts=${ts} in channel ${channelId}`);
716
+ process.exit(1);
717
+ }
718
+ const originalText = typeof original.text === "string" ? original.text : "";
719
+
720
+ const code = safetyCode(channelId, ts, originalText);
721
+ if (args.code !== code) {
722
+ const dest = await destLabel(token, channelId, ref);
723
+ requireCode(args.code, code, [
724
+ `--- Deleting message -------------------------`,
725
+ ` → ${dest} at ${slackTsToIso(ts)}`,
726
+ ...originalText.split("\n").map((l) => ` ${l}`),
727
+ `---------------------------------------------`,
728
+ ]);
729
+ }
730
+
731
+ await deleteMessage(token, channelId, ts);
732
+ console.log(`✓ Deleted (ts: ${ts})`);
733
+ }
734
+
594
735
  // --- send ---
595
736
  interface SendArgs {
596
737
  target: string;
597
738
  message: string;
598
- thread?: string;
599
739
  code?: string;
600
740
  channelId?: string;
601
741
  userId?: string;
742
+ asBot?: boolean;
743
+ broadcast?: boolean;
744
+ }
745
+
746
+ // Detect the silent-failure footgun: DMing yourself with your own user token.
747
+ // Slack does not notify you about messages you sent to yourself, so an
748
+ // escalation DM via `send @me` (or @your-own-handle) is delivered but never
749
+ // surfaces. Returns true when ref names the token's own user.
750
+ async function isSelfDm(token: string, ref: string): Promise<boolean> {
751
+ if (!ref.startsWith("@")) return false;
752
+ const name = ref.slice(1).toLowerCase().replace(/[^a-z0-9]/g, "");
753
+ if (name === "me" || name === "you") return true;
754
+ try {
755
+ const self = await authTest(token);
756
+ const selfName = (self.user ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
757
+ return selfName !== "" && selfName === name;
758
+ } catch {
759
+ return false;
760
+ }
602
761
  }
762
+
603
763
  async function cmdSend(token: string, args: SendArgs): Promise<void> {
764
+ const { ref, threadTs } = parseTargetThread(args.target);
765
+
766
+ // Guard the self-DM footgun before doing anything else, unless sending as the
767
+ // bot (which delivers a notifiable DM from a different identity).
768
+ if (!args.asBot && !args.channelId && !args.userId && await isSelfDm(token, ref)) {
769
+ console.error(
770
+ `Warning: "${ref}" is a DM to yourself — Slack will NOT notify you of your own message.\n` +
771
+ ` To reach yourself with a notification, send as the bot: slack send '${ref}' '...' --as-bot`,
772
+ );
773
+ }
774
+
604
775
  let channelId: string;
605
776
  if (args.channelId) channelId = args.channelId;
606
777
  else if (args.userId) channelId = await openDm(token, args.userId);
607
- else if (args.target.startsWith("#") || args.target.startsWith("@")) {
608
- channelId = await resolveChannel(token, args.target);
609
- } else {
610
- console.error(`Error: target must be #channel-name or @username (got: ${args.target})`);
611
- console.error("Use --channel-id=<ID> or --user-id=<ID> to send by raw ID.");
612
- process.exit(1);
613
- }
778
+ else channelId = await resolveChannel(token, ref);
614
779
 
615
- // Fetch last 1 message for context hash
616
- const ctx = (await history(token, channelId, 1)) as Record<string, Json>;
617
- const lastMsg = asArray(ctx.messages).map(asRecord)
618
- .filter((m) => m.subtype === undefined || m.subtype === null)[0];
619
- const lastText = typeof lastMsg?.text === "string" ? lastMsg.text : "";
620
- const lastUser = typeof lastMsg?.user === "string" ? lastMsg.user : "?";
780
+ // Fetch last 1 message for context hash. Fail-soft: a bot token without
781
+ // im:history (or channels:history) can still send only the preview of the
782
+ // prior message is lost, so default to empty rather than blocking the send.
783
+ let lastText = "";
784
+ let lastUser = "?";
785
+ try {
786
+ const ctx = (await history(token, channelId, 1)) as Record<string, Json>;
787
+ const lastMsg = asArray(ctx.messages).map(asRecord)
788
+ .filter((m) => m.subtype === undefined || m.subtype === null)[0];
789
+ lastText = typeof lastMsg?.text === "string" ? lastMsg.text : "";
790
+ // Resolve the author's user ID to a display name for the preview; fall back
791
+ // to the bot username, then the raw ID. userName() is fail-soft.
792
+ lastUser = typeof lastMsg?.user === "string"
793
+ ? await userName(token, lastMsg.user)
794
+ : typeof lastMsg?.username === "string"
795
+ ? lastMsg.username
796
+ : "?";
797
+ } catch {
798
+ // best-effort preview only
799
+ }
621
800
 
622
- const code = safetyCode(lastText, args.message);
801
+ // Hash covers the destination too — a code minted for one channel/thread
802
+ // cannot confirm a send to another.
803
+ const code = safetyCode(channelId, threadTs ?? "", lastText, args.message);
623
804
 
624
805
  if (args.code !== code) {
806
+ const dest = await destLabel(token, channelId, ref);
807
+ const destLine = threadTs
808
+ ? ` → ${dest} thread of ${await threadParentLine(token, channelId, threadTs)} — THREAD REPLY`
809
+ : ` → ${dest} — NEW top-level message`;
625
810
  requireCode(args.code, code, [
626
811
  `--- Last message in channel ------------------`,
627
812
  ` ${lastUser}: ${lastText.split("\n")[0]?.slice(0, 100) ?? "(empty)"}`,
628
813
  `--- Sending ----------------------------------`,
629
- ` To: ${args.target}${args.thread ? ` (thread ${args.thread})` : ""}`,
814
+ destLine,
630
815
  ` Message: ${args.message}`,
631
816
  `--------------------------------────────────`,
632
817
  ]);
633
818
  }
634
- const ts = await slackSend(token, channelId, args.message, args.thread);
635
- console.log(`✓ Sent (ts: ${ts})`);
819
+ const ts = await slackSend(token, channelId, args.message, threadTs, args.broadcast);
820
+ let permalink = "";
821
+ try {
822
+ permalink = await getPermalink(token, channelId, ts);
823
+ } catch {
824
+ // fail-soft: getPermalink failure (rate limit, network, etc.) should not
825
+ // mask send success — fall back to ts-only output below.
826
+ }
827
+ if (permalink) {
828
+ console.log(`✓ Sent: ${permalink}`);
829
+ } else {
830
+ console.log(`✓ Sent (ts: ${ts})`);
831
+ }
832
+
833
+ // After a bot DM, check the app can actually carry a two-way conversation —
834
+ // an escalation the user can't reply to (Messages Tab off / missing scopes)
835
+ // is worse than useless. Non-fatal: the send already succeeded.
836
+ if (args.asBot && channelId.startsWith("D")) {
837
+ try {
838
+ const diag = await diagnoseBotMessaging(token);
839
+ if (!diag.ok) {
840
+ for (const line of formatDiagnosis(diag, true)) console.error(line);
841
+ } else {
842
+ // Scopes are fine, but the Messages Tab toggle isn't API-detectable.
843
+ const appUrl = diag.appId ? `https://api.slack.com/apps/${diag.appId}` : "https://api.slack.com/apps";
844
+ console.error(
845
+ `Note: if ${args.target} can't reply (app messaging may be off), ` +
846
+ `enable Messages Tab: ${appUrl} → App Home.`,
847
+ );
848
+ }
849
+ } catch {
850
+ // diagnosis is best-effort; never turn a successful send into a failure.
851
+ }
852
+ }
853
+ }
854
+
855
+ async function cmdDoctor(token: string): Promise<void> {
856
+ const diag = await diagnoseBotMessaging(token);
857
+ for (const line of formatDiagnosis(diag, false)) console.log(line);
858
+ if (!diag.ok) process.exit(1);
636
859
  }
637
860
 
638
861
  // --- schedule ---
@@ -647,21 +870,17 @@ interface ScheduleSendArgs {
647
870
  target: string;
648
871
  message: string;
649
872
  at: string;
650
- thread?: string;
651
873
  code?: string;
652
874
  channelId?: string;
653
875
  userId?: string;
654
876
  }
655
877
  async function cmdScheduleSend(token: string, args: ScheduleSendArgs): Promise<void> {
878
+ const { ref, threadTs } = parseTargetThread(args.target);
879
+
656
880
  let channelId: string;
657
881
  if (args.channelId) channelId = args.channelId;
658
882
  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
- }
883
+ else channelId = await resolveChannel(token, ref);
665
884
 
666
885
  const postAt = parsePostAt(args.at);
667
886
  const postAtDate = new Date(postAt * 1000).toISOString();
@@ -670,13 +889,13 @@ async function cmdScheduleSend(token: string, args: ScheduleSendArgs): Promise<v
670
889
  if (args.code !== code) {
671
890
  requireCode(args.code, code, [
672
891
  `--- Scheduling message -----------------------`,
673
- ` To: ${args.target}${args.thread ? ` (thread ${args.thread})` : ""}`,
892
+ ` To: ${ref}${threadTs ? ` (thread ${threadTs})` : ""}`,
674
893
  ` At: ${postAtDate} (Unix: ${postAt})`,
675
894
  ` Message: ${args.message}`,
676
895
  `---------------------------------------------`,
677
896
  ]);
678
897
  }
679
- const id = await scheduleMessage(token, channelId, args.message, postAt, args.thread);
898
+ const id = await scheduleMessage(token, channelId, args.message, postAt, threadTs);
680
899
  console.log(`✓ Scheduled (id: ${id}, at: ${postAtDate})`);
681
900
  }
682
901
 
@@ -727,7 +946,6 @@ interface UploadArgs {
727
946
  target: string;
728
947
  filePaths: string[];
729
948
  title?: string;
730
- thread?: string;
731
949
  comment?: string;
732
950
  code?: string;
733
951
  channelId?: string;
@@ -744,15 +962,12 @@ async function cmdUpload(token: string, args: UploadArgs): Promise<void> {
744
962
  }
745
963
  }
746
964
 
965
+ const { ref, threadTs } = parseTargetThread(args.target);
966
+
747
967
  let channelId: string;
748
968
  if (args.channelId) channelId = args.channelId;
749
969
  else if (args.userId) channelId = await openDm(token, args.userId);
750
- else if (args.target.startsWith("#") || args.target.startsWith("@")) {
751
- channelId = await resolveChannel(token, args.target);
752
- } else {
753
- console.error(`Error: target must be #channel-name or @username (got: ${args.target})`);
754
- process.exit(1);
755
- }
970
+ else channelId = await resolveChannel(token, ref);
756
971
 
757
972
  function fmtSize(n: number): string {
758
973
  return n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`;
@@ -768,7 +983,7 @@ async function cmdUpload(token: string, args: UploadArgs): Promise<void> {
768
983
  // Safety code covers the full batch — single-file code is identical to the old formula.
769
984
  const code = safetyCode(channelId, ...files.flatMap((f) => [f.fp, f.title]));
770
985
  if (args.code !== code) {
771
- const destLine = ` To: ${args.target}${args.thread ? ` (thread ${args.thread})` : ""}`;
986
+ const destLine = ` To: ${ref}${threadTs ? ` (thread ${threadTs})` : ""}`;
772
987
  const lines = isBatch
773
988
  ? [
774
989
  `--- Uploading ${files.length} files ------------------------`,
@@ -791,7 +1006,7 @@ async function cmdUpload(token: string, args: UploadArgs): Promise<void> {
791
1006
  for (let i = 0; i < total; i++) {
792
1007
  const f = files[i]!;
793
1008
  const uploadOpts: { title?: string; threadTs?: string; initialComment?: string } = { title: f.title };
794
- if (args.thread !== undefined) uploadOpts.threadTs = args.thread;
1009
+ if (threadTs !== undefined) uploadOpts.threadTs = threadTs;
795
1010
  if (args.comment !== undefined && i === 0) uploadOpts.initialComment = args.comment;
796
1011
  const { fileId, permalink } = await uploadFile(token, channelId, f.fp, uploadOpts);
797
1012
  const prefix = total > 1 ? `[${i + 1}/${total}] ` : "";
@@ -831,10 +1046,13 @@ async function main(): Promise<void> {
831
1046
  "Browse messages",
832
1047
  (y) => y
833
1048
  .positional("target", { type: "string", describe: "#channel, @user, or URL" })
834
- .option("limit", { alias: "n", type: "number", default: 20, describe: "Number of messages" }),
1049
+ .option("limit", { alias: "n", type: "number", default: 20, describe: "Number of messages" })
1050
+ .option("format", { type: "string", choices: ["text", "jsonl"] as const, default: "text", describe: "jsonl exposes raw ts/user/text (incl. external-guest user IDs)" })
1051
+ .option("json", { type: "boolean", default: false, describe: "Alias for --format=jsonl" }),
835
1052
  async (argv) => {
836
1053
  const token = tok(argv as W);
837
- if (argv.target) await cmdMsgsTarget(token, argv.target, argv.limit);
1054
+ const format = argv.json ? "jsonl" : (argv.format as "text" | "jsonl");
1055
+ if (argv.target) await cmdMsgsTarget(token, argv.target, argv.limit, format);
838
1056
  else await cmdMsgs(token);
839
1057
  },
840
1058
  )
@@ -844,9 +1062,12 @@ async function main(): Promise<void> {
844
1062
  (y) => y
845
1063
  .positional("target", { type: "string", demandOption: true })
846
1064
  .positional("ts", { type: "string", demandOption: true })
847
- .option("limit", { alias: "n", type: "number", default: 100 }),
1065
+ .option("limit", { alias: "n", type: "number", default: 100 })
1066
+ .option("format", { type: "string", choices: ["text", "jsonl"] as const, default: "text", describe: "jsonl exposes raw ts/user/text (incl. external-guest user IDs)" })
1067
+ .option("json", { type: "boolean", default: false, describe: "Alias for --format=jsonl" }),
848
1068
  async (argv) => {
849
- await cmdThread(tok(argv as W), argv.target!, argv.ts!, argv.limit);
1069
+ const format = argv.json ? "jsonl" : (argv.format as "text" | "jsonl");
1070
+ await cmdThread(tok(argv as W), argv.target!, argv.ts!, argv.limit, format);
850
1071
  },
851
1072
  )
852
1073
  .command(
@@ -1019,19 +1240,75 @@ async function main(): Promise<void> {
1019
1240
  "send <target> <message>",
1020
1241
  "Send a message (confirm-hash safety gate)",
1021
1242
  (y) => y
1022
- .positional("target", { type: "string", demandOption: true })
1243
+ .positional("target", { type: "string", demandOption: true, describe: "#chan, @user, #chan:thread_ts, or permalink (a message permalink replies in its thread)" })
1023
1244
  .positional("message", { type: "string", demandOption: true })
1024
- .option("thread", { type: "string", describe: "Thread timestamp" })
1025
1245
  .option("code", { type: "string", describe: "Safety hash to confirm send" })
1026
1246
  .option("channel-id", { type: "string", describe: "Raw channel ID" })
1027
- .option("user-id", { type: "string", describe: "Raw user ID (opens DM)" }),
1247
+ .option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
1248
+ .option("as-bot", { type: "boolean", default: false, describe: "Send via the bot token (xoxb / SLACK_BOT_TOKEN) so a DM notifies the recipient and can be two-way" })
1249
+ .option("broadcast", { type: "boolean", default: false, describe: "Also send to channel: broadcast a threaded reply back to the channel (Slack's \"Also send to #channel\" checkbox). Only effective with a thread target." }),
1028
1250
  async (argv) => {
1029
1251
  const args: SendArgs = { target: argv.target!, message: argv.message! };
1030
- if (argv.thread) args.thread = argv.thread;
1031
1252
  if (argv.code) args.code = argv.code;
1032
1253
  if (argv["channel-id"]) args.channelId = argv["channel-id"];
1033
1254
  if (argv["user-id"]) args.userId = argv["user-id"];
1034
- await cmdSend(tok(argv as W), args);
1255
+ if (argv.broadcast) args.broadcast = true;
1256
+ let sendToken: string;
1257
+ if (argv["as-bot"]) {
1258
+ const botToken = resolveBotToken();
1259
+ if (!botToken) {
1260
+ console.error(
1261
+ "Error: --as-bot needs a bot token, but no xoxb- token was found.\n" +
1262
+ " Set SLACK_BOT_TOKEN=xoxb-... in ~/.config/slack-cli/.env (or your shell).",
1263
+ );
1264
+ process.exit(1);
1265
+ }
1266
+ // Resolve @user → user_id with the *user* token (has users:read), then
1267
+ // let cmdSend open the DM with the bot token (im:write). This avoids
1268
+ // requiring users:read on the bot and guarantees the DM is bot↔user
1269
+ // (not the user's own self-DM).
1270
+ if (!args.channelId && !args.userId && args.target.startsWith("@")) {
1271
+ try {
1272
+ args.userId = await resolveUserId(tok(argv as W), args.target);
1273
+ } catch (e: unknown) {
1274
+ console.error(
1275
+ `Error: could not resolve ${args.target} to a user for the bot DM.\n` +
1276
+ ` ${e instanceof Error ? e.message : String(e)}\n` +
1277
+ ` Tip: pass --user-id <Uxxxx> to skip the lookup.`,
1278
+ );
1279
+ process.exit(1);
1280
+ }
1281
+ }
1282
+ sendToken = botToken;
1283
+ args.asBot = true;
1284
+ } else {
1285
+ sendToken = tok(argv as W);
1286
+ }
1287
+ // Print a clean error instead of yargs' usage dump when a send fails at
1288
+ // runtime (e.g. missing scope, channel not found). The confirm gate
1289
+ // exits via process.exit, so it is unaffected.
1290
+ try {
1291
+ await cmdSend(sendToken, args);
1292
+ } catch (e: unknown) {
1293
+ console.error(e instanceof Error ? e.message : String(e));
1294
+ process.exit(1);
1295
+ }
1296
+ },
1297
+ )
1298
+ .command(
1299
+ "doctor",
1300
+ "Check the bot token (xoxb) can carry a two-way DM (scopes + Messages Tab)",
1301
+ (y) => y,
1302
+ async () => {
1303
+ const botToken = resolveBotToken();
1304
+ if (!botToken) {
1305
+ console.error(
1306
+ "Error: slack doctor checks the bot token, but no xoxb- token was found.\n" +
1307
+ " Set SLACK_BOT_TOKEN=xoxb-... in ~/.config/slack-cli/.env (or your shell).",
1308
+ );
1309
+ process.exit(1);
1310
+ }
1311
+ await cmdDoctor(botToken);
1035
1312
  },
1036
1313
  )
1037
1314
  .command(
@@ -1042,16 +1319,14 @@ async function main(): Promise<void> {
1042
1319
  "send <target> <message>",
1043
1320
  "Schedule a message for later delivery",
1044
1321
  (y2) => y2
1045
- .positional("target", { type: "string", demandOption: true })
1322
+ .positional("target", { type: "string", demandOption: true, describe: "#chan, @user, #chan:thread_ts, or permalink (a message permalink replies in its thread)" })
1046
1323
  .positional("message", { type: "string", demandOption: true })
1047
1324
  .option("at", { type: "string", demandOption: true, describe: "Delivery time (ISO datetime or Unix ts)" })
1048
- .option("thread", { type: "string", describe: "Thread timestamp" })
1049
1325
  .option("code", { type: "string", describe: "Safety hash to confirm" })
1050
1326
  .option("channel-id", { type: "string", describe: "Raw channel ID" })
1051
1327
  .option("user-id", { type: "string", describe: "Raw user ID (opens DM)" }),
1052
1328
  async (argv) => {
1053
1329
  const args: ScheduleSendArgs = { target: argv.target!, message: argv.message!, at: argv.at! };
1054
- if (argv.thread) args.thread = argv.thread;
1055
1330
  if (argv.code) args.code = argv.code;
1056
1331
  if (argv["channel-id"]) args.channelId = argv["channel-id"];
1057
1332
  if (argv["user-id"]) args.userId = argv["user-id"];
@@ -1102,14 +1377,27 @@ async function main(): Promise<void> {
1102
1377
  await cmdEdit(tok(argv as W), args);
1103
1378
  },
1104
1379
  )
1380
+ .command(
1381
+ "delete <target>",
1382
+ "Delete a sent message (confirm-hash safety gate)",
1383
+ (y) => y
1384
+ .positional("target", { type: "string", demandOption: true, describe: "#chan:ts, @user:ts, or permalink" })
1385
+ .option("code", { type: "string", describe: "Safety hash to confirm delete" })
1386
+ .option("channel-id", { type: "string", describe: "Raw channel ID" }),
1387
+ async (argv) => {
1388
+ const args: DeleteArgs = { target: argv.target! };
1389
+ if (argv.code) args.code = argv.code;
1390
+ if (argv["channel-id"]) args.channelId = argv["channel-id"];
1391
+ await cmdDelete(tok(argv as W), args);
1392
+ },
1393
+ )
1105
1394
  .command(
1106
1395
  "upload <target> <file..>",
1107
1396
  "Upload one or more files to a channel or DM",
1108
1397
  (y) => y
1109
- .positional("target", { type: "string", demandOption: true })
1398
+ .positional("target", { type: "string", demandOption: true, describe: "#chan, @user, #chan:thread_ts, or permalink (a message permalink replies in its thread)" })
1110
1399
  .positional("file", { type: "string", array: true, demandOption: true, describe: "Path(s) to file(s)" })
1111
1400
  .option("title", { type: "string", describe: "Title (single file only)" })
1112
- .option("thread", { type: "string", describe: "Thread timestamp" })
1113
1401
  .option("comment", { type: "string", describe: "Initial comment (first file)" })
1114
1402
  .option("code", { type: "string", describe: "4-hex safety code to confirm upload" })
1115
1403
  .option("channel-id", { type: "string" })
@@ -1117,7 +1405,6 @@ async function main(): Promise<void> {
1117
1405
  async (argv) => {
1118
1406
  const args: UploadArgs = { target: argv.target!, filePaths: argv.file as string[] };
1119
1407
  if (argv.title) args.title = argv.title;
1120
- if (argv.thread) args.thread = argv.thread;
1121
1408
  if (argv.comment) args.comment = argv.comment;
1122
1409
  if (argv.code) args.code = argv.code;
1123
1410
  if (argv["channel-id"]) args.channelId = argv["channel-id"];
@@ -1226,9 +1513,30 @@ async function main(): Promise<void> {
1226
1513
  "auth",
1227
1514
  "Authentication and workspace management",
1228
1515
  (y) => y
1516
+ .command(
1517
+ "token",
1518
+ "Add a workspace — paste an existing xoxp-/xoxb- token",
1519
+ (y2) => y2
1520
+ .option("token", { type: "string", describe: "Token to save directly (non-interactive)" })
1521
+ .option("name", { type: "string", describe: "Workspace name (used with --token)" }),
1522
+ async (argv) => {
1523
+ await cmdAuthToken({
1524
+ ...(argv.token !== undefined ? { token: argv.token } : {}),
1525
+ ...(argv.name !== undefined ? { name: argv.name } : {}),
1526
+ });
1527
+ },
1528
+ )
1529
+ .command(
1530
+ "app",
1531
+ "Create a new Slack app and obtain a token (guided wizard)",
1532
+ (y2) => y2.option("bot", { type: "boolean", describe: "Create a bot token (xoxb-) instead of user (xoxp-)" }),
1533
+ async (argv) => {
1534
+ await cmdAuthApp({ ...(argv.bot !== undefined ? { bot: argv.bot } : {}) });
1535
+ },
1536
+ )
1229
1537
  .command(
1230
1538
  "login",
1231
- "Add a workspace (interactive or --token for non-interactive)",
1539
+ "Interactive auth wizard (all auth methods: desktop app, token, new app)",
1232
1540
  (y2) => y2
1233
1541
  .option("token", { type: "string", describe: "Token to save directly (non-interactive)" })
1234
1542
  .option("name", { type: "string", describe: "Workspace name (used with --token)" }),
@@ -1258,7 +1566,7 @@ async function main(): Promise<void> {
1258
1566
  },
1259
1567
  )
1260
1568
  .command(
1261
- ["rm <name>", "remove <name>"],
1569
+ ["logout <name>", "rm <name>", "remove <name>"],
1262
1570
  "Remove a workspace",
1263
1571
  (y2) => y2.positional("name", { type: "string", demandOption: true }),
1264
1572
  (argv) => {
@@ -1266,6 +1574,24 @@ async function main(): Promise<void> {
1266
1574
  console.log(`Removed workspace "${argv.name}"`);
1267
1575
  },
1268
1576
  )
1577
+ .command(
1578
+ ["chrome", "cookie"],
1579
+ "Attach Chrome browser xoxd cookie to a workspace (macOS, interactive)",
1580
+ (y2) => y2
1581
+ .option("workspace", { type: "string", alias: "w", describe: "Workspace name to update (default: active)" }),
1582
+ async (argv) => {
1583
+ await cmdAuthChrome({ ...(argv.workspace !== undefined ? { workspace: argv.workspace } : {}) });
1584
+ },
1585
+ )
1586
+ .command(
1587
+ "firefox",
1588
+ "Attach Firefox browser xoxd cookie to a workspace (all platforms)",
1589
+ (y2) => y2
1590
+ .option("workspace", { type: "string", alias: "w", describe: "Workspace name to update (default: active)" }),
1591
+ async (argv) => {
1592
+ await cmdAuthFirefox({ ...(argv.workspace !== undefined ? { workspace: argv.workspace } : {}) });
1593
+ },
1594
+ )
1269
1595
  .command("$0", false as unknown as string, () => {}, () => { y.showHelp(); process.exit(0); }),
1270
1596
  )
1271
1597
  .command(
@@ -1275,8 +1601,11 @@ async function main(): Promise<void> {
1275
1601
  .positional("target", { type: "string", describe: "#channel or @user to follow" })
1276
1602
  .option("since", { type: "string", describe: "Backfill from N ago (e.g. 10m, 2h, 1d)" })
1277
1603
  .option("thread", { type: "string", describe: "Follow a single thread by timestamp" })
1604
+ .option("watch-thread", { type: "string", describe: "Watch the channel's top-level timeline AND one thread's replies together (ts or permalink)" })
1278
1605
  .option("me", { type: "boolean", default: false, describe: "Filter to messages that mention you" })
1279
1606
  .option("interval", { type: "number", default: 60000, describe: "Poll interval in ms (default 60s; use --interval=3000 for near-real-time)" })
1607
+ .option("timeout", { type: "string", describe: "Auto-stop after this long (e.g. 30m, 2h). Exit code 0 even if nothing arrived." })
1608
+ .option("exit-on-message", { type: "boolean", default: false, describe: "Stop as soon as the first new message from someone else arrives (wait-for-reply)" })
1280
1609
  .option("rtm", { type: "boolean", default: true, describe: "Use RTM WebSocket when available (xoxc + cookie); pass --no-rtm to force polling" }),
1281
1610
  async (argv) => {
1282
1611
  const token = tok(argv as W);
@@ -1286,8 +1615,11 @@ async function main(): Promise<void> {
1286
1615
  await cmdTail(token, argv.target, {
1287
1616
  ...(argv.since !== undefined ? { since: argv.since } : {}),
1288
1617
  ...(argv.thread !== undefined ? { thread: argv.thread } : {}),
1618
+ ...(argv["watch-thread"] !== undefined ? { watchThread: argv["watch-thread"] as string } : {}),
1289
1619
  me: argv.me,
1290
1620
  interval: argv.interval,
1621
+ ...(argv.timeout !== undefined ? { timeout: argv.timeout } : {}),
1622
+ ...(argv["exit-on-message"] === true ? { exitOnMessage: true } : {}),
1291
1623
  ...(cookie !== undefined ? { cookie } : {}),
1292
1624
  ...(argv.rtm === false ? { noRtm: true } : {}),
1293
1625
  }, signal.signal);