slack-term 1.13.0 → 1.15.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,17 +175,36 @@ 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}`;
174
189
  }
175
190
 
176
191
  // --- msgs <target> — channel/DM history with timestamps ---
177
192
  async function cmdMsgsTarget(token: string, target: string, limit: number): Promise<void> {
193
+ const parsed = parseSlackPermalink(target);
178
194
  const channelId = await resolveChannel(token, target);
179
- const hist = (await history(token, channelId, limit)) as Record<string, Json>;
180
- const msgs = asArray(hist.messages).map(asRecord);
181
195
  const cache = new Map<string, string>();
182
- for (const m of msgs.reverse()) {
183
- console.log(await formatMsgLine(token, m, cache));
196
+ if (parsed?.threadTs) {
197
+ const resp = (await replies(token, channelId, parsed.threadTs, limit)) as Record<string, Json>;
198
+ const msgs = asArray(resp.messages).map(asRecord);
199
+ for (const m of msgs) {
200
+ console.log(await formatMsgLine(token, m, cache));
201
+ }
202
+ } else {
203
+ const hist = (await history(token, channelId, limit)) as Record<string, Json>;
204
+ const msgs = asArray(hist.messages).map(asRecord);
205
+ for (const m of msgs.reverse()) {
206
+ console.log(await formatMsgLine(token, m, cache));
207
+ }
184
208
  }
185
209
  }
186
210
 
@@ -539,6 +563,69 @@ function splitRefTs(s: string): { ref: string; ts?: string } {
539
563
  return { ref: s };
540
564
  }
541
565
 
566
+ /** Parse a send/upload target that may embed a thread_ts after `:`.
567
+ * Accepts: `#chan:1700000000.000100`, `@user:ts`, `RAWID:ts`, Slack permalink, or plain ref.
568
+ * A message permalink targets that message's thread — `?thread_ts=` (the parent) wins when
569
+ * present, otherwise the message's own ts. A channel-only permalink stays top-level. */
570
+ function parseTargetThread(s: string): { ref: string; threadTs?: string } {
571
+ const url = parseSlackPermalink(s);
572
+ if (url) {
573
+ const threadTs = url.threadTs ?? url.ts;
574
+ return threadTs ? { ref: url.channel, threadTs } : { ref: url.channel };
575
+ }
576
+ const colon = s.indexOf(":");
577
+ if (colon > 0) {
578
+ const maybeTs = s.slice(colon + 1);
579
+ if (/^\d{10}\.\d{6}$/.test(maybeTs)) return { ref: s.slice(0, colon), threadTs: maybeTs };
580
+ if (/^\d{4}-\d{2}-\d{2}T/.test(maybeTs)) return { ref: s.slice(0, colon), threadTs: isoToSlackTs(maybeTs) };
581
+ }
582
+ return { ref: s };
583
+ }
584
+
585
+ /** Human-readable destination label for confirm gates, e.g. "#symval (C0B0L6SSAMD)".
586
+ * When ref is already a #channel/@user label, keep it; otherwise resolve the channel
587
+ * name via conversations.info (fail-soft: a raw ID is still unambiguous). */
588
+ async function destLabel(token: string, channelId: string, ref: string): Promise<string> {
589
+ let name = ref;
590
+ if (!ref.startsWith("#") && !ref.startsWith("@")) {
591
+ try {
592
+ const info = asRecord((await conversationInfo(token, channelId)) as Json);
593
+ const ch = asRecord(info.channel);
594
+ if (ch.is_im === true) {
595
+ const uid = typeof ch.user === "string" ? ch.user : "";
596
+ name = uid ? `@${await userName(token, uid)}` : channelId;
597
+ } else {
598
+ name = typeof ch.name === "string" ? `#${ch.name}` : channelId;
599
+ }
600
+ } catch {
601
+ name = channelId;
602
+ }
603
+ }
604
+ return name === channelId ? channelId : `${name} (${channelId})`;
605
+ }
606
+
607
+ /** One-line thread-parent description for confirm gates: "2026-06-11 08:05 @handle: head…".
608
+ * Fail-soft: returns the raw ts if the parent cannot be fetched. */
609
+ async function threadParentLine(token: string, channelId: string, threadTs: string): Promise<string> {
610
+ try {
611
+ const resp = (await replies(token, channelId, threadTs, 1)) as Record<string, Json>;
612
+ const msgs = asArray(resp.messages).map(asRecord);
613
+ const parent = msgs.find((m) => String(m.ts) === threadTs) ?? msgs[0];
614
+ if (!parent) return threadTs;
615
+ const author = typeof parent.user === "string"
616
+ ? await userName(token, parent.user)
617
+ : typeof parent.username === "string"
618
+ ? parent.username
619
+ : "?";
620
+ const head = (typeof parent.text === "string" ? parent.text : "").split("\n")[0] ?? "";
621
+ const headShort = head.length > 40 ? `${head.slice(0, 40)}…` : head;
622
+ const stamp = slackTsToIso(threadTs).slice(0, 16).replace("T", " ");
623
+ return `${stamp} @${author}${headShort ? `: ${headShort}` : ""}`;
624
+ } catch {
625
+ return threadTs;
626
+ }
627
+ }
628
+
542
629
  // --- edit ---
543
630
  interface EditArgs {
544
631
  target: string;
@@ -582,48 +669,172 @@ async function cmdEdit(token: string, args: EditArgs): Promise<void> {
582
669
  console.log(`✓ Edited (ts: ${newTs})`);
583
670
  }
584
671
 
672
+ // --- delete ---
673
+ interface DeleteArgs {
674
+ target: string;
675
+ code?: string;
676
+ channelId?: string;
677
+ }
678
+ async function cmdDelete(token: string, args: DeleteArgs): Promise<void> {
679
+ const { ref, ts } = splitRefTs(args.target);
680
+ if (!ts) {
681
+ console.error("Error: target must embed a message ts (e.g. #chan:2026-05-11T06:01:04.000100 or a Slack permalink URL)");
682
+ process.exit(2);
683
+ }
684
+
685
+ let channelId: string;
686
+ if (args.channelId) channelId = args.channelId;
687
+ else channelId = await resolveChannel(token, ref);
688
+
689
+ // Fetch the message to display what is being deleted and compute the safety hash.
690
+ const resp = (await replies(token, channelId, ts, 1)) as Record<string, Json>;
691
+ const msgs = asArray(resp.messages).map(asRecord);
692
+ const original = msgs.find((m) => String(m.ts) === ts);
693
+ if (!original) {
694
+ console.error(`Message not found at ts=${ts} in channel ${channelId}`);
695
+ process.exit(1);
696
+ }
697
+ const originalText = typeof original.text === "string" ? original.text : "";
698
+
699
+ const code = safetyCode(channelId, ts, originalText);
700
+ if (args.code !== code) {
701
+ const dest = await destLabel(token, channelId, ref);
702
+ requireCode(args.code, code, [
703
+ `--- Deleting message -------------------------`,
704
+ ` → ${dest} at ${slackTsToIso(ts)}`,
705
+ ...originalText.split("\n").map((l) => ` ${l}`),
706
+ `---------------------------------------------`,
707
+ ]);
708
+ }
709
+
710
+ await deleteMessage(token, channelId, ts);
711
+ console.log(`✓ Deleted (ts: ${ts})`);
712
+ }
713
+
585
714
  // --- send ---
586
715
  interface SendArgs {
587
716
  target: string;
588
717
  message: string;
589
- thread?: string;
590
718
  code?: string;
591
719
  channelId?: string;
592
720
  userId?: string;
721
+ asBot?: boolean;
722
+ broadcast?: boolean;
593
723
  }
724
+
725
+ // Detect the silent-failure footgun: DMing yourself with your own user token.
726
+ // Slack does not notify you about messages you sent to yourself, so an
727
+ // escalation DM via `send @me` (or @your-own-handle) is delivered but never
728
+ // surfaces. Returns true when ref names the token's own user.
729
+ async function isSelfDm(token: string, ref: string): Promise<boolean> {
730
+ if (!ref.startsWith("@")) return false;
731
+ const name = ref.slice(1).toLowerCase().replace(/[^a-z0-9]/g, "");
732
+ if (name === "me" || name === "you") return true;
733
+ try {
734
+ const self = await authTest(token);
735
+ const selfName = (self.user ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
736
+ return selfName !== "" && selfName === name;
737
+ } catch {
738
+ return false;
739
+ }
740
+ }
741
+
594
742
  async function cmdSend(token: string, args: SendArgs): Promise<void> {
743
+ const { ref, threadTs } = parseTargetThread(args.target);
744
+
745
+ // Guard the self-DM footgun before doing anything else, unless sending as the
746
+ // bot (which delivers a notifiable DM from a different identity).
747
+ if (!args.asBot && !args.channelId && !args.userId && await isSelfDm(token, ref)) {
748
+ console.error(
749
+ `Warning: "${ref}" is a DM to yourself — Slack will NOT notify you of your own message.\n` +
750
+ ` To reach yourself with a notification, send as the bot: slack send '${ref}' '...' --as-bot`,
751
+ );
752
+ }
753
+
595
754
  let channelId: string;
596
755
  if (args.channelId) channelId = args.channelId;
597
756
  else if (args.userId) channelId = await openDm(token, args.userId);
598
- else if (args.target.startsWith("#") || args.target.startsWith("@")) {
599
- channelId = await resolveChannel(token, args.target);
600
- } else {
601
- console.error(`Error: target must be #channel-name or @username (got: ${args.target})`);
602
- console.error("Use --channel-id=<ID> or --user-id=<ID> to send by raw ID.");
603
- process.exit(1);
604
- }
757
+ else channelId = await resolveChannel(token, ref);
605
758
 
606
- // Fetch last 1 message for context hash
607
- const ctx = (await history(token, channelId, 1)) as Record<string, Json>;
608
- const lastMsg = asArray(ctx.messages).map(asRecord)
609
- .filter((m) => m.subtype === undefined || m.subtype === null)[0];
610
- const lastText = typeof lastMsg?.text === "string" ? lastMsg.text : "";
611
- const lastUser = typeof lastMsg?.user === "string" ? lastMsg.user : "?";
759
+ // Fetch last 1 message for context hash. Fail-soft: a bot token without
760
+ // im:history (or channels:history) can still send only the preview of the
761
+ // prior message is lost, so default to empty rather than blocking the send.
762
+ let lastText = "";
763
+ let lastUser = "?";
764
+ try {
765
+ const ctx = (await history(token, channelId, 1)) as Record<string, Json>;
766
+ const lastMsg = asArray(ctx.messages).map(asRecord)
767
+ .filter((m) => m.subtype === undefined || m.subtype === null)[0];
768
+ lastText = typeof lastMsg?.text === "string" ? lastMsg.text : "";
769
+ // Resolve the author's user ID to a display name for the preview; fall back
770
+ // to the bot username, then the raw ID. userName() is fail-soft.
771
+ lastUser = typeof lastMsg?.user === "string"
772
+ ? await userName(token, lastMsg.user)
773
+ : typeof lastMsg?.username === "string"
774
+ ? lastMsg.username
775
+ : "?";
776
+ } catch {
777
+ // best-effort preview only
778
+ }
612
779
 
613
- const code = safetyCode(lastText, args.message);
780
+ // Hash covers the destination too — a code minted for one channel/thread
781
+ // cannot confirm a send to another.
782
+ const code = safetyCode(channelId, threadTs ?? "", lastText, args.message);
614
783
 
615
784
  if (args.code !== code) {
785
+ const dest = await destLabel(token, channelId, ref);
786
+ const destLine = threadTs
787
+ ? ` → ${dest} thread of ${await threadParentLine(token, channelId, threadTs)} — THREAD REPLY`
788
+ : ` → ${dest} — NEW top-level message`;
616
789
  requireCode(args.code, code, [
617
790
  `--- Last message in channel ------------------`,
618
791
  ` ${lastUser}: ${lastText.split("\n")[0]?.slice(0, 100) ?? "(empty)"}`,
619
792
  `--- Sending ----------------------------------`,
620
- ` To: ${args.target}${args.thread ? ` (thread ${args.thread})` : ""}`,
793
+ destLine,
621
794
  ` Message: ${args.message}`,
622
795
  `--------------------------------────────────`,
623
796
  ]);
624
797
  }
625
- const ts = await slackSend(token, channelId, args.message, args.thread);
626
- console.log(`✓ Sent (ts: ${ts})`);
798
+ const ts = await slackSend(token, channelId, args.message, threadTs, args.broadcast);
799
+ let permalink = "";
800
+ try {
801
+ permalink = await getPermalink(token, channelId, ts);
802
+ } catch {
803
+ // fail-soft: getPermalink failure (rate limit, network, etc.) should not
804
+ // mask send success — fall back to ts-only output below.
805
+ }
806
+ if (permalink) {
807
+ console.log(`✓ Sent: ${permalink}`);
808
+ } else {
809
+ console.log(`✓ Sent (ts: ${ts})`);
810
+ }
811
+
812
+ // After a bot DM, check the app can actually carry a two-way conversation —
813
+ // an escalation the user can't reply to (Messages Tab off / missing scopes)
814
+ // is worse than useless. Non-fatal: the send already succeeded.
815
+ if (args.asBot && channelId.startsWith("D")) {
816
+ try {
817
+ const diag = await diagnoseBotMessaging(token);
818
+ if (!diag.ok) {
819
+ for (const line of formatDiagnosis(diag, true)) console.error(line);
820
+ } else {
821
+ // Scopes are fine, but the Messages Tab toggle isn't API-detectable.
822
+ const appUrl = diag.appId ? `https://api.slack.com/apps/${diag.appId}` : "https://api.slack.com/apps";
823
+ console.error(
824
+ `Note: if ${args.target} can't reply (app messaging may be off), ` +
825
+ `enable Messages Tab: ${appUrl} → App Home.`,
826
+ );
827
+ }
828
+ } catch {
829
+ // diagnosis is best-effort; never turn a successful send into a failure.
830
+ }
831
+ }
832
+ }
833
+
834
+ async function cmdDoctor(token: string): Promise<void> {
835
+ const diag = await diagnoseBotMessaging(token);
836
+ for (const line of formatDiagnosis(diag, false)) console.log(line);
837
+ if (!diag.ok) process.exit(1);
627
838
  }
628
839
 
629
840
  // --- schedule ---
@@ -638,21 +849,17 @@ interface ScheduleSendArgs {
638
849
  target: string;
639
850
  message: string;
640
851
  at: string;
641
- thread?: string;
642
852
  code?: string;
643
853
  channelId?: string;
644
854
  userId?: string;
645
855
  }
646
856
  async function cmdScheduleSend(token: string, args: ScheduleSendArgs): Promise<void> {
857
+ const { ref, threadTs } = parseTargetThread(args.target);
858
+
647
859
  let channelId: string;
648
860
  if (args.channelId) channelId = args.channelId;
649
861
  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
- }
862
+ else channelId = await resolveChannel(token, ref);
656
863
 
657
864
  const postAt = parsePostAt(args.at);
658
865
  const postAtDate = new Date(postAt * 1000).toISOString();
@@ -661,13 +868,13 @@ async function cmdScheduleSend(token: string, args: ScheduleSendArgs): Promise<v
661
868
  if (args.code !== code) {
662
869
  requireCode(args.code, code, [
663
870
  `--- Scheduling message -----------------------`,
664
- ` To: ${args.target}${args.thread ? ` (thread ${args.thread})` : ""}`,
871
+ ` To: ${ref}${threadTs ? ` (thread ${threadTs})` : ""}`,
665
872
  ` At: ${postAtDate} (Unix: ${postAt})`,
666
873
  ` Message: ${args.message}`,
667
874
  `---------------------------------------------`,
668
875
  ]);
669
876
  }
670
- const id = await scheduleMessage(token, channelId, args.message, postAt, args.thread);
877
+ const id = await scheduleMessage(token, channelId, args.message, postAt, threadTs);
671
878
  console.log(`✓ Scheduled (id: ${id}, at: ${postAtDate})`);
672
879
  }
673
880
 
@@ -718,7 +925,6 @@ interface UploadArgs {
718
925
  target: string;
719
926
  filePaths: string[];
720
927
  title?: string;
721
- thread?: string;
722
928
  comment?: string;
723
929
  code?: string;
724
930
  channelId?: string;
@@ -735,15 +941,12 @@ async function cmdUpload(token: string, args: UploadArgs): Promise<void> {
735
941
  }
736
942
  }
737
943
 
944
+ const { ref, threadTs } = parseTargetThread(args.target);
945
+
738
946
  let channelId: string;
739
947
  if (args.channelId) channelId = args.channelId;
740
948
  else if (args.userId) channelId = await openDm(token, args.userId);
741
- else if (args.target.startsWith("#") || args.target.startsWith("@")) {
742
- channelId = await resolveChannel(token, args.target);
743
- } else {
744
- console.error(`Error: target must be #channel-name or @username (got: ${args.target})`);
745
- process.exit(1);
746
- }
949
+ else channelId = await resolveChannel(token, ref);
747
950
 
748
951
  function fmtSize(n: number): string {
749
952
  return n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`;
@@ -759,7 +962,7 @@ async function cmdUpload(token: string, args: UploadArgs): Promise<void> {
759
962
  // Safety code covers the full batch — single-file code is identical to the old formula.
760
963
  const code = safetyCode(channelId, ...files.flatMap((f) => [f.fp, f.title]));
761
964
  if (args.code !== code) {
762
- const destLine = ` To: ${args.target}${args.thread ? ` (thread ${args.thread})` : ""}`;
965
+ const destLine = ` To: ${ref}${threadTs ? ` (thread ${threadTs})` : ""}`;
763
966
  const lines = isBatch
764
967
  ? [
765
968
  `--- Uploading ${files.length} files ------------------------`,
@@ -782,7 +985,7 @@ async function cmdUpload(token: string, args: UploadArgs): Promise<void> {
782
985
  for (let i = 0; i < total; i++) {
783
986
  const f = files[i]!;
784
987
  const uploadOpts: { title?: string; threadTs?: string; initialComment?: string } = { title: f.title };
785
- if (args.thread !== undefined) uploadOpts.threadTs = args.thread;
988
+ if (threadTs !== undefined) uploadOpts.threadTs = threadTs;
786
989
  if (args.comment !== undefined && i === 0) uploadOpts.initialComment = args.comment;
787
990
  const { fileId, permalink } = await uploadFile(token, channelId, f.fp, uploadOpts);
788
991
  const prefix = total > 1 ? `[${i + 1}/${total}] ` : "";
@@ -1010,19 +1213,75 @@ async function main(): Promise<void> {
1010
1213
  "send <target> <message>",
1011
1214
  "Send a message (confirm-hash safety gate)",
1012
1215
  (y) => y
1013
- .positional("target", { type: "string", demandOption: true })
1216
+ .positional("target", { type: "string", demandOption: true, describe: "#chan, @user, #chan:thread_ts, or permalink (a message permalink replies in its thread)" })
1014
1217
  .positional("message", { type: "string", demandOption: true })
1015
- .option("thread", { type: "string", describe: "Thread timestamp" })
1016
1218
  .option("code", { type: "string", describe: "Safety hash to confirm send" })
1017
1219
  .option("channel-id", { type: "string", describe: "Raw channel ID" })
1018
- .option("user-id", { type: "string", describe: "Raw user ID (opens DM)" }),
1220
+ .option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
1221
+ .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" })
1222
+ .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." }),
1019
1223
  async (argv) => {
1020
1224
  const args: SendArgs = { target: argv.target!, message: argv.message! };
1021
- if (argv.thread) args.thread = argv.thread;
1022
1225
  if (argv.code) args.code = argv.code;
1023
1226
  if (argv["channel-id"]) args.channelId = argv["channel-id"];
1024
1227
  if (argv["user-id"]) args.userId = argv["user-id"];
1025
- await cmdSend(tok(argv as W), args);
1228
+ if (argv.broadcast) args.broadcast = true;
1229
+ let sendToken: string;
1230
+ if (argv["as-bot"]) {
1231
+ const botToken = resolveBotToken();
1232
+ if (!botToken) {
1233
+ console.error(
1234
+ "Error: --as-bot needs a bot token, but no xoxb- token was found.\n" +
1235
+ " Set SLACK_BOT_TOKEN=xoxb-... in ~/.config/slack-cli/.env (or your shell).",
1236
+ );
1237
+ process.exit(1);
1238
+ }
1239
+ // Resolve @user → user_id with the *user* token (has users:read), then
1240
+ // let cmdSend open the DM with the bot token (im:write). This avoids
1241
+ // requiring users:read on the bot and guarantees the DM is bot↔user
1242
+ // (not the user's own self-DM).
1243
+ if (!args.channelId && !args.userId && args.target.startsWith("@")) {
1244
+ try {
1245
+ args.userId = await resolveUserId(tok(argv as W), args.target);
1246
+ } catch (e: unknown) {
1247
+ console.error(
1248
+ `Error: could not resolve ${args.target} to a user for the bot DM.\n` +
1249
+ ` ${e instanceof Error ? e.message : String(e)}\n` +
1250
+ ` Tip: pass --user-id <Uxxxx> to skip the lookup.`,
1251
+ );
1252
+ process.exit(1);
1253
+ }
1254
+ }
1255
+ sendToken = botToken;
1256
+ args.asBot = true;
1257
+ } else {
1258
+ sendToken = tok(argv as W);
1259
+ }
1260
+ // Print a clean error instead of yargs' usage dump when a send fails at
1261
+ // runtime (e.g. missing scope, channel not found). The confirm gate
1262
+ // exits via process.exit, so it is unaffected.
1263
+ try {
1264
+ await cmdSend(sendToken, args);
1265
+ } catch (e: unknown) {
1266
+ console.error(e instanceof Error ? e.message : String(e));
1267
+ process.exit(1);
1268
+ }
1269
+ },
1270
+ )
1271
+ .command(
1272
+ "doctor",
1273
+ "Check the bot token (xoxb) can carry a two-way DM (scopes + Messages Tab)",
1274
+ (y) => y,
1275
+ async () => {
1276
+ const botToken = resolveBotToken();
1277
+ if (!botToken) {
1278
+ console.error(
1279
+ "Error: slack doctor checks the bot token, but no xoxb- token was found.\n" +
1280
+ " Set SLACK_BOT_TOKEN=xoxb-... in ~/.config/slack-cli/.env (or your shell).",
1281
+ );
1282
+ process.exit(1);
1283
+ }
1284
+ await cmdDoctor(botToken);
1026
1285
  },
1027
1286
  )
1028
1287
  .command(
@@ -1033,16 +1292,14 @@ async function main(): Promise<void> {
1033
1292
  "send <target> <message>",
1034
1293
  "Schedule a message for later delivery",
1035
1294
  (y2) => y2
1036
- .positional("target", { type: "string", demandOption: true })
1295
+ .positional("target", { type: "string", demandOption: true, describe: "#chan, @user, #chan:thread_ts, or permalink (a message permalink replies in its thread)" })
1037
1296
  .positional("message", { type: "string", demandOption: true })
1038
1297
  .option("at", { type: "string", demandOption: true, describe: "Delivery time (ISO datetime or Unix ts)" })
1039
- .option("thread", { type: "string", describe: "Thread timestamp" })
1040
1298
  .option("code", { type: "string", describe: "Safety hash to confirm" })
1041
1299
  .option("channel-id", { type: "string", describe: "Raw channel ID" })
1042
1300
  .option("user-id", { type: "string", describe: "Raw user ID (opens DM)" }),
1043
1301
  async (argv) => {
1044
1302
  const args: ScheduleSendArgs = { target: argv.target!, message: argv.message!, at: argv.at! };
1045
- if (argv.thread) args.thread = argv.thread;
1046
1303
  if (argv.code) args.code = argv.code;
1047
1304
  if (argv["channel-id"]) args.channelId = argv["channel-id"];
1048
1305
  if (argv["user-id"]) args.userId = argv["user-id"];
@@ -1093,14 +1350,27 @@ async function main(): Promise<void> {
1093
1350
  await cmdEdit(tok(argv as W), args);
1094
1351
  },
1095
1352
  )
1353
+ .command(
1354
+ "delete <target>",
1355
+ "Delete a sent message (confirm-hash safety gate)",
1356
+ (y) => y
1357
+ .positional("target", { type: "string", demandOption: true, describe: "#chan:ts, @user:ts, or permalink" })
1358
+ .option("code", { type: "string", describe: "Safety hash to confirm delete" })
1359
+ .option("channel-id", { type: "string", describe: "Raw channel ID" }),
1360
+ async (argv) => {
1361
+ const args: DeleteArgs = { target: argv.target! };
1362
+ if (argv.code) args.code = argv.code;
1363
+ if (argv["channel-id"]) args.channelId = argv["channel-id"];
1364
+ await cmdDelete(tok(argv as W), args);
1365
+ },
1366
+ )
1096
1367
  .command(
1097
1368
  "upload <target> <file..>",
1098
1369
  "Upload one or more files to a channel or DM",
1099
1370
  (y) => y
1100
- .positional("target", { type: "string", demandOption: true })
1371
+ .positional("target", { type: "string", demandOption: true, describe: "#chan, @user, #chan:thread_ts, or permalink (a message permalink replies in its thread)" })
1101
1372
  .positional("file", { type: "string", array: true, demandOption: true, describe: "Path(s) to file(s)" })
1102
1373
  .option("title", { type: "string", describe: "Title (single file only)" })
1103
- .option("thread", { type: "string", describe: "Thread timestamp" })
1104
1374
  .option("comment", { type: "string", describe: "Initial comment (first file)" })
1105
1375
  .option("code", { type: "string", describe: "4-hex safety code to confirm upload" })
1106
1376
  .option("channel-id", { type: "string" })
@@ -1108,7 +1378,6 @@ async function main(): Promise<void> {
1108
1378
  async (argv) => {
1109
1379
  const args: UploadArgs = { target: argv.target!, filePaths: argv.file as string[] };
1110
1380
  if (argv.title) args.title = argv.title;
1111
- if (argv.thread) args.thread = argv.thread;
1112
1381
  if (argv.comment) args.comment = argv.comment;
1113
1382
  if (argv.code) args.code = argv.code;
1114
1383
  if (argv["channel-id"]) args.channelId = argv["channel-id"];
@@ -1217,9 +1486,30 @@ async function main(): Promise<void> {
1217
1486
  "auth",
1218
1487
  "Authentication and workspace management",
1219
1488
  (y) => y
1489
+ .command(
1490
+ "token",
1491
+ "Add a workspace — paste an existing xoxp-/xoxb- token",
1492
+ (y2) => y2
1493
+ .option("token", { type: "string", describe: "Token to save directly (non-interactive)" })
1494
+ .option("name", { type: "string", describe: "Workspace name (used with --token)" }),
1495
+ async (argv) => {
1496
+ await cmdAuthToken({
1497
+ ...(argv.token !== undefined ? { token: argv.token } : {}),
1498
+ ...(argv.name !== undefined ? { name: argv.name } : {}),
1499
+ });
1500
+ },
1501
+ )
1502
+ .command(
1503
+ "app",
1504
+ "Create a new Slack app and obtain a token (guided wizard)",
1505
+ (y2) => y2.option("bot", { type: "boolean", describe: "Create a bot token (xoxb-) instead of user (xoxp-)" }),
1506
+ async (argv) => {
1507
+ await cmdAuthApp({ ...(argv.bot !== undefined ? { bot: argv.bot } : {}) });
1508
+ },
1509
+ )
1220
1510
  .command(
1221
1511
  "login",
1222
- "Add a workspace (interactive or --token for non-interactive)",
1512
+ "Interactive auth wizard (all auth methods: desktop app, token, new app)",
1223
1513
  (y2) => y2
1224
1514
  .option("token", { type: "string", describe: "Token to save directly (non-interactive)" })
1225
1515
  .option("name", { type: "string", describe: "Workspace name (used with --token)" }),
@@ -1249,7 +1539,7 @@ async function main(): Promise<void> {
1249
1539
  },
1250
1540
  )
1251
1541
  .command(
1252
- ["rm <name>", "remove <name>"],
1542
+ ["logout <name>", "rm <name>", "remove <name>"],
1253
1543
  "Remove a workspace",
1254
1544
  (y2) => y2.positional("name", { type: "string", demandOption: true }),
1255
1545
  (argv) => {
@@ -1257,6 +1547,24 @@ async function main(): Promise<void> {
1257
1547
  console.log(`Removed workspace "${argv.name}"`);
1258
1548
  },
1259
1549
  )
1550
+ .command(
1551
+ ["chrome", "cookie"],
1552
+ "Attach Chrome browser xoxd cookie to a workspace (macOS, interactive)",
1553
+ (y2) => y2
1554
+ .option("workspace", { type: "string", alias: "w", describe: "Workspace name to update (default: active)" }),
1555
+ async (argv) => {
1556
+ await cmdAuthChrome({ ...(argv.workspace !== undefined ? { workspace: argv.workspace } : {}) });
1557
+ },
1558
+ )
1559
+ .command(
1560
+ "firefox",
1561
+ "Attach Firefox browser xoxd cookie to a workspace (all platforms)",
1562
+ (y2) => y2
1563
+ .option("workspace", { type: "string", alias: "w", describe: "Workspace name to update (default: active)" }),
1564
+ async (argv) => {
1565
+ await cmdAuthFirefox({ ...(argv.workspace !== undefined ? { workspace: argv.workspace } : {}) });
1566
+ },
1567
+ )
1260
1568
  .command("$0", false as unknown as string, () => {}, () => { y.showHelp(); process.exit(0); }),
1261
1569
  )
1262
1570
  .command(
@@ -1266,17 +1574,27 @@ async function main(): Promise<void> {
1266
1574
  .positional("target", { type: "string", describe: "#channel or @user to follow" })
1267
1575
  .option("since", { type: "string", describe: "Backfill from N ago (e.g. 10m, 2h, 1d)" })
1268
1576
  .option("thread", { type: "string", describe: "Follow a single thread by timestamp" })
1577
+ .option("watch-thread", { type: "string", describe: "Watch the channel's top-level timeline AND one thread's replies together (ts or permalink)" })
1269
1578
  .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)" }),
1579
+ .option("interval", { type: "number", default: 60000, describe: "Poll interval in ms (default 60s; use --interval=3000 for near-real-time)" })
1580
+ .option("timeout", { type: "string", describe: "Auto-stop after this long (e.g. 30m, 2h). Exit code 0 even if nothing arrived." })
1581
+ .option("exit-on-message", { type: "boolean", default: false, describe: "Stop as soon as the first new message from someone else arrives (wait-for-reply)" })
1582
+ .option("rtm", { type: "boolean", default: true, describe: "Use RTM WebSocket when available (xoxc + cookie); pass --no-rtm to force polling" }),
1271
1583
  async (argv) => {
1272
1584
  const token = tok(argv as W);
1585
+ const cookie = ck(argv as W);
1273
1586
  const signal = new AbortController();
1274
1587
  process.on("SIGINT", () => { signal.abort(); process.exit(0); });
1275
1588
  await cmdTail(token, argv.target, {
1276
1589
  ...(argv.since !== undefined ? { since: argv.since } : {}),
1277
1590
  ...(argv.thread !== undefined ? { thread: argv.thread } : {}),
1591
+ ...(argv["watch-thread"] !== undefined ? { watchThread: argv["watch-thread"] as string } : {}),
1278
1592
  me: argv.me,
1279
1593
  interval: argv.interval,
1594
+ ...(argv.timeout !== undefined ? { timeout: argv.timeout } : {}),
1595
+ ...(argv["exit-on-message"] === true ? { exitOnMessage: true } : {}),
1596
+ ...(cookie !== undefined ? { cookie } : {}),
1597
+ ...(argv.rtm === false ? { noRtm: true } : {}),
1280
1598
  }, signal.signal);
1281
1599
  },
1282
1600
  )