slack-term 1.14.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,7 +175,17 @@ 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 ---
@@ -548,6 +563,69 @@ function splitRefTs(s: string): { ref: string; ts?: string } {
548
563
  return { ref: s };
549
564
  }
550
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
+
551
629
  // --- edit ---
552
630
  interface EditArgs {
553
631
  target: string;
@@ -591,48 +669,172 @@ async function cmdEdit(token: string, args: EditArgs): Promise<void> {
591
669
  console.log(`✓ Edited (ts: ${newTs})`);
592
670
  }
593
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
+
594
714
  // --- send ---
595
715
  interface SendArgs {
596
716
  target: string;
597
717
  message: string;
598
- thread?: string;
599
718
  code?: string;
600
719
  channelId?: string;
601
720
  userId?: string;
721
+ asBot?: boolean;
722
+ broadcast?: boolean;
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
+ }
602
740
  }
741
+
603
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
+
604
754
  let channelId: string;
605
755
  if (args.channelId) channelId = args.channelId;
606
756
  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
- }
757
+ else channelId = await resolveChannel(token, ref);
614
758
 
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 : "?";
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
+ }
621
779
 
622
- 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);
623
783
 
624
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`;
625
789
  requireCode(args.code, code, [
626
790
  `--- Last message in channel ------------------`,
627
791
  ` ${lastUser}: ${lastText.split("\n")[0]?.slice(0, 100) ?? "(empty)"}`,
628
792
  `--- Sending ----------------------------------`,
629
- ` To: ${args.target}${args.thread ? ` (thread ${args.thread})` : ""}`,
793
+ destLine,
630
794
  ` Message: ${args.message}`,
631
795
  `--------------------------------────────────`,
632
796
  ]);
633
797
  }
634
- const ts = await slackSend(token, channelId, args.message, args.thread);
635
- 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);
636
838
  }
637
839
 
638
840
  // --- schedule ---
@@ -647,21 +849,17 @@ interface ScheduleSendArgs {
647
849
  target: string;
648
850
  message: string;
649
851
  at: string;
650
- thread?: string;
651
852
  code?: string;
652
853
  channelId?: string;
653
854
  userId?: string;
654
855
  }
655
856
  async function cmdScheduleSend(token: string, args: ScheduleSendArgs): Promise<void> {
857
+ const { ref, threadTs } = parseTargetThread(args.target);
858
+
656
859
  let channelId: string;
657
860
  if (args.channelId) channelId = args.channelId;
658
861
  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
- }
862
+ else channelId = await resolveChannel(token, ref);
665
863
 
666
864
  const postAt = parsePostAt(args.at);
667
865
  const postAtDate = new Date(postAt * 1000).toISOString();
@@ -670,13 +868,13 @@ async function cmdScheduleSend(token: string, args: ScheduleSendArgs): Promise<v
670
868
  if (args.code !== code) {
671
869
  requireCode(args.code, code, [
672
870
  `--- Scheduling message -----------------------`,
673
- ` To: ${args.target}${args.thread ? ` (thread ${args.thread})` : ""}`,
871
+ ` To: ${ref}${threadTs ? ` (thread ${threadTs})` : ""}`,
674
872
  ` At: ${postAtDate} (Unix: ${postAt})`,
675
873
  ` Message: ${args.message}`,
676
874
  `---------------------------------------------`,
677
875
  ]);
678
876
  }
679
- const id = await scheduleMessage(token, channelId, args.message, postAt, args.thread);
877
+ const id = await scheduleMessage(token, channelId, args.message, postAt, threadTs);
680
878
  console.log(`✓ Scheduled (id: ${id}, at: ${postAtDate})`);
681
879
  }
682
880
 
@@ -727,7 +925,6 @@ interface UploadArgs {
727
925
  target: string;
728
926
  filePaths: string[];
729
927
  title?: string;
730
- thread?: string;
731
928
  comment?: string;
732
929
  code?: string;
733
930
  channelId?: string;
@@ -744,15 +941,12 @@ async function cmdUpload(token: string, args: UploadArgs): Promise<void> {
744
941
  }
745
942
  }
746
943
 
944
+ const { ref, threadTs } = parseTargetThread(args.target);
945
+
747
946
  let channelId: string;
748
947
  if (args.channelId) channelId = args.channelId;
749
948
  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
- }
949
+ else channelId = await resolveChannel(token, ref);
756
950
 
757
951
  function fmtSize(n: number): string {
758
952
  return n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`;
@@ -768,7 +962,7 @@ async function cmdUpload(token: string, args: UploadArgs): Promise<void> {
768
962
  // Safety code covers the full batch — single-file code is identical to the old formula.
769
963
  const code = safetyCode(channelId, ...files.flatMap((f) => [f.fp, f.title]));
770
964
  if (args.code !== code) {
771
- const destLine = ` To: ${args.target}${args.thread ? ` (thread ${args.thread})` : ""}`;
965
+ const destLine = ` To: ${ref}${threadTs ? ` (thread ${threadTs})` : ""}`;
772
966
  const lines = isBatch
773
967
  ? [
774
968
  `--- Uploading ${files.length} files ------------------------`,
@@ -791,7 +985,7 @@ async function cmdUpload(token: string, args: UploadArgs): Promise<void> {
791
985
  for (let i = 0; i < total; i++) {
792
986
  const f = files[i]!;
793
987
  const uploadOpts: { title?: string; threadTs?: string; initialComment?: string } = { title: f.title };
794
- if (args.thread !== undefined) uploadOpts.threadTs = args.thread;
988
+ if (threadTs !== undefined) uploadOpts.threadTs = threadTs;
795
989
  if (args.comment !== undefined && i === 0) uploadOpts.initialComment = args.comment;
796
990
  const { fileId, permalink } = await uploadFile(token, channelId, f.fp, uploadOpts);
797
991
  const prefix = total > 1 ? `[${i + 1}/${total}] ` : "";
@@ -1019,19 +1213,75 @@ async function main(): Promise<void> {
1019
1213
  "send <target> <message>",
1020
1214
  "Send a message (confirm-hash safety gate)",
1021
1215
  (y) => y
1022
- .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)" })
1023
1217
  .positional("message", { type: "string", demandOption: true })
1024
- .option("thread", { type: "string", describe: "Thread timestamp" })
1025
1218
  .option("code", { type: "string", describe: "Safety hash to confirm send" })
1026
1219
  .option("channel-id", { type: "string", describe: "Raw channel ID" })
1027
- .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." }),
1028
1223
  async (argv) => {
1029
1224
  const args: SendArgs = { target: argv.target!, message: argv.message! };
1030
- if (argv.thread) args.thread = argv.thread;
1031
1225
  if (argv.code) args.code = argv.code;
1032
1226
  if (argv["channel-id"]) args.channelId = argv["channel-id"];
1033
1227
  if (argv["user-id"]) args.userId = argv["user-id"];
1034
- 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);
1035
1285
  },
1036
1286
  )
1037
1287
  .command(
@@ -1042,16 +1292,14 @@ async function main(): Promise<void> {
1042
1292
  "send <target> <message>",
1043
1293
  "Schedule a message for later delivery",
1044
1294
  (y2) => y2
1045
- .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)" })
1046
1296
  .positional("message", { type: "string", demandOption: true })
1047
1297
  .option("at", { type: "string", demandOption: true, describe: "Delivery time (ISO datetime or Unix ts)" })
1048
- .option("thread", { type: "string", describe: "Thread timestamp" })
1049
1298
  .option("code", { type: "string", describe: "Safety hash to confirm" })
1050
1299
  .option("channel-id", { type: "string", describe: "Raw channel ID" })
1051
1300
  .option("user-id", { type: "string", describe: "Raw user ID (opens DM)" }),
1052
1301
  async (argv) => {
1053
1302
  const args: ScheduleSendArgs = { target: argv.target!, message: argv.message!, at: argv.at! };
1054
- if (argv.thread) args.thread = argv.thread;
1055
1303
  if (argv.code) args.code = argv.code;
1056
1304
  if (argv["channel-id"]) args.channelId = argv["channel-id"];
1057
1305
  if (argv["user-id"]) args.userId = argv["user-id"];
@@ -1102,14 +1350,27 @@ async function main(): Promise<void> {
1102
1350
  await cmdEdit(tok(argv as W), args);
1103
1351
  },
1104
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
+ )
1105
1367
  .command(
1106
1368
  "upload <target> <file..>",
1107
1369
  "Upload one or more files to a channel or DM",
1108
1370
  (y) => y
1109
- .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)" })
1110
1372
  .positional("file", { type: "string", array: true, demandOption: true, describe: "Path(s) to file(s)" })
1111
1373
  .option("title", { type: "string", describe: "Title (single file only)" })
1112
- .option("thread", { type: "string", describe: "Thread timestamp" })
1113
1374
  .option("comment", { type: "string", describe: "Initial comment (first file)" })
1114
1375
  .option("code", { type: "string", describe: "4-hex safety code to confirm upload" })
1115
1376
  .option("channel-id", { type: "string" })
@@ -1117,7 +1378,6 @@ async function main(): Promise<void> {
1117
1378
  async (argv) => {
1118
1379
  const args: UploadArgs = { target: argv.target!, filePaths: argv.file as string[] };
1119
1380
  if (argv.title) args.title = argv.title;
1120
- if (argv.thread) args.thread = argv.thread;
1121
1381
  if (argv.comment) args.comment = argv.comment;
1122
1382
  if (argv.code) args.code = argv.code;
1123
1383
  if (argv["channel-id"]) args.channelId = argv["channel-id"];
@@ -1226,9 +1486,30 @@ async function main(): Promise<void> {
1226
1486
  "auth",
1227
1487
  "Authentication and workspace management",
1228
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
+ )
1229
1510
  .command(
1230
1511
  "login",
1231
- "Add a workspace (interactive or --token for non-interactive)",
1512
+ "Interactive auth wizard (all auth methods: desktop app, token, new app)",
1232
1513
  (y2) => y2
1233
1514
  .option("token", { type: "string", describe: "Token to save directly (non-interactive)" })
1234
1515
  .option("name", { type: "string", describe: "Workspace name (used with --token)" }),
@@ -1258,7 +1539,7 @@ async function main(): Promise<void> {
1258
1539
  },
1259
1540
  )
1260
1541
  .command(
1261
- ["rm <name>", "remove <name>"],
1542
+ ["logout <name>", "rm <name>", "remove <name>"],
1262
1543
  "Remove a workspace",
1263
1544
  (y2) => y2.positional("name", { type: "string", demandOption: true }),
1264
1545
  (argv) => {
@@ -1266,6 +1547,24 @@ async function main(): Promise<void> {
1266
1547
  console.log(`Removed workspace "${argv.name}"`);
1267
1548
  },
1268
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
+ )
1269
1568
  .command("$0", false as unknown as string, () => {}, () => { y.showHelp(); process.exit(0); }),
1270
1569
  )
1271
1570
  .command(
@@ -1275,8 +1574,11 @@ async function main(): Promise<void> {
1275
1574
  .positional("target", { type: "string", describe: "#channel or @user to follow" })
1276
1575
  .option("since", { type: "string", describe: "Backfill from N ago (e.g. 10m, 2h, 1d)" })
1277
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)" })
1278
1578
  .option("me", { type: "boolean", default: false, describe: "Filter to messages that mention you" })
1279
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)" })
1280
1582
  .option("rtm", { type: "boolean", default: true, describe: "Use RTM WebSocket when available (xoxc + cookie); pass --no-rtm to force polling" }),
1281
1583
  async (argv) => {
1282
1584
  const token = tok(argv as W);
@@ -1286,8 +1588,11 @@ async function main(): Promise<void> {
1286
1588
  await cmdTail(token, argv.target, {
1287
1589
  ...(argv.since !== undefined ? { since: argv.since } : {}),
1288
1590
  ...(argv.thread !== undefined ? { thread: argv.thread } : {}),
1591
+ ...(argv["watch-thread"] !== undefined ? { watchThread: argv["watch-thread"] as string } : {}),
1289
1592
  me: argv.me,
1290
1593
  interval: argv.interval,
1594
+ ...(argv.timeout !== undefined ? { timeout: argv.timeout } : {}),
1595
+ ...(argv["exit-on-message"] === true ? { exitOnMessage: true } : {}),
1291
1596
  ...(cookie !== undefined ? { cookie } : {}),
1292
1597
  ...(argv.rtm === false ? { noRtm: true } : {}),
1293
1598
  }, signal.signal);