slack-term 1.23.0 → 1.24.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,7 +8,7 @@ import { join } from "node:path";
8
8
 
9
9
  import yargs from "yargs";
10
10
  import { hideBin } from "yargs/helpers";
11
- import { listProfiles, removeProfile, resolveBotToken, resolveCookie, resolveToken, useProfile } from "./profiles.ts";
11
+ import { listProfiles, removeProfile, resolveBotToken, resolveCookie, resolveToken, useProfile, type Profile } from "./profiles.ts";
12
12
  import { diagnoseBotMessaging, formatDiagnosis } from "./botdoctor.ts";
13
13
  import { cmdAuthLogin, cmdAuthChrome, cmdAuthFirefox, cmdAuthToken, cmdAuthApp } from "./auth.ts";
14
14
  import { cmdTail } from "./tail.ts";
@@ -28,6 +28,7 @@ import {
28
28
  reactionAdd,
29
29
  reactionRemove,
30
30
  filesInfo,
31
+ filesList,
31
32
  history,
32
33
  listConversations,
33
34
  listDrafts,
@@ -387,8 +388,52 @@ async function cmdChannels(token: string, limit: number, filter?: string, all?:
387
388
  }
388
389
 
389
390
  // --- search ---
390
- async function cmdSearch(token: string, query: string, count: number, json: boolean): Promise<void> {
391
- const resp = await searchAll(token, query, count);
391
+
392
+ /** search.messages (and most read/write Web API methods) reject a bot token
393
+ * (xoxb-) with not_allowed_token_type — it's a user-only endpoint. Find a
394
+ * sibling profile that holds a user token (xoxp-/xoxc-) for the *same*
395
+ * workspace (teamId) as the bot profile currently in use, so the caller can
396
+ * retry with it automatically instead of just erroring. Deliberately does
397
+ * NOT fall back to a user-token profile for a different workspace — that
398
+ * would silently run the search against the wrong (possibly private)
399
+ * workspace instead of failing loudly. Returns undefined (no fallback) when
400
+ * the current token isn't a known profile at all, since its workspace can't
401
+ * be verified. */
402
+ function findUserTokenProfile(currentToken: string): { name: string; profile: Profile } | undefined {
403
+ const isUserToken = (t: string) => t.startsWith("xoxp-") || t.startsWith("xoxc-");
404
+ const profiles = listProfiles();
405
+ const current = profiles.find((p) => p.profile.token === currentToken);
406
+ if (!current) return undefined;
407
+ return profiles.find((p) => isUserToken(p.profile.token) && p.profile.teamId === current.profile.teamId);
408
+ }
409
+
410
+ async function cmdSearch(token: string, query: string, count: number, json: boolean, cookie?: string): Promise<void> {
411
+ let searchToken = token;
412
+ let searchCookie = cookie;
413
+ let resp: Json;
414
+ try {
415
+ resp = await searchAll(searchToken, query, count, searchCookie);
416
+ } catch (e: unknown) {
417
+ const msg = e instanceof Error ? e.message : String(e);
418
+ if (token.startsWith("xoxb-") && msg.includes("not_allowed_token_type")) {
419
+ const fallback = findUserTokenProfile(token);
420
+ if (fallback) {
421
+ console.error(`Note: search.messages needs a user token — the active profile is a bot token (xoxb-); falling back to profile "${fallback.name}".`);
422
+ searchToken = fallback.profile.token;
423
+ searchCookie = fallback.profile.cookie;
424
+ resp = await searchAll(searchToken, query, count, searchCookie);
425
+ } else {
426
+ console.error(
427
+ "Error: search requires a user token (xoxp-/xoxc-) — the active profile is a bot token (xoxb-), which Slack rejects for search.messages.\n" +
428
+ " Add a user-token profile: slack auth login (or slack auth token)\n" +
429
+ " Then select it: slack auth use <name> (or pass --workspace <name>)",
430
+ );
431
+ process.exit(1);
432
+ }
433
+ } else {
434
+ throw e;
435
+ }
436
+ }
392
437
  if (json) {
393
438
  console.log(JSON.stringify(resp, null, 2));
394
439
  return;
@@ -403,7 +448,7 @@ async function cmdSearch(token: string, query: string, count: number, json: bool
403
448
  if (isIm && rawName.startsWith("U")) {
404
449
  const handleKey = "@" + rawName;
405
450
  if (!cache.has(handleKey)) {
406
- const [, h] = await userInfoPair(token, rawName);
451
+ const [, h] = await userInfoPair(searchToken, rawName, searchCookie);
407
452
  cache.set(handleKey, h);
408
453
  }
409
454
  chLabel = `@${cache.get(handleKey) ?? rawName}`;
@@ -412,7 +457,7 @@ async function cmdSearch(token: string, query: string, count: number, json: bool
412
457
  } else {
413
458
  chLabel = `#${rawName}`;
414
459
  }
415
- console.log(await formatMsgLine(token, m, cache, chLabel));
460
+ console.log(await formatMsgLine(searchToken, m, cache, chLabel));
416
461
  }
417
462
  }
418
463
 
@@ -705,15 +750,15 @@ function parseTargetThread(s: string): { ref: string; threadTs?: string } {
705
750
  /** Human-readable destination label for confirm gates, e.g. "#symval (C0B0L6SSAMD)".
706
751
  * When ref is already a #channel/@user label, keep it; otherwise resolve the channel
707
752
  * name via conversations.info (fail-soft: a raw ID is still unambiguous). */
708
- async function destLabel(token: string, channelId: string, ref: string): Promise<string> {
753
+ async function destLabel(token: string, channelId: string, ref: string, cookie?: string): Promise<string> {
709
754
  let name = ref;
710
755
  if (!ref.startsWith("#") && !ref.startsWith("@")) {
711
756
  try {
712
- const info = asRecord((await conversationInfo(token, channelId)) as Json);
757
+ const info = asRecord((await conversationInfo(token, channelId, cookie)) as Json);
713
758
  const ch = asRecord(info.channel);
714
759
  if (ch.is_im === true) {
715
760
  const uid = typeof ch.user === "string" ? ch.user : "";
716
- name = uid ? `@${await userName(token, uid)}` : channelId;
761
+ name = uid ? `@${await userName(token, uid, cookie)}` : channelId;
717
762
  } else {
718
763
  name = typeof ch.name === "string" ? `#${ch.name}` : channelId;
719
764
  }
@@ -726,9 +771,9 @@ async function destLabel(token: string, channelId: string, ref: string): Promise
726
771
 
727
772
  /** One-line description of a single thread message for confirm gates:
728
773
  * "2026-06-11 08:05 @handle: head…". `headLen` bounds the text preview. */
729
- async function threadMsgLine(token: string, m: Record<string, Json>, headLen = 60): Promise<string> {
774
+ async function threadMsgLine(token: string, m: Record<string, Json>, headLen = 60, cookie?: string): Promise<string> {
730
775
  const author = typeof m.user === "string"
731
- ? await userName(token, m.user)
776
+ ? await userName(token, m.user, cookie)
732
777
  : typeof m.username === "string"
733
778
  ? m.username
734
779
  : "?";
@@ -741,10 +786,10 @@ async function threadMsgLine(token: string, m: Record<string, Json>, headLen = 6
741
786
 
742
787
  /** One-line thread-parent description for confirm gates: "2026-06-11 08:05 @handle: head…".
743
788
  * Fail-soft: returns the raw ts if the parent cannot be found. */
744
- async function threadParentLine(token: string, threadTs: string, threadMsgs: Record<string, Json>[]): Promise<string> {
789
+ async function threadParentLine(token: string, threadTs: string, threadMsgs: Record<string, Json>[], cookie?: string): Promise<string> {
745
790
  const parent = threadMsgs.find((m) => String(m.ts) === threadTs) ?? threadMsgs[0];
746
791
  if (!parent) return threadTs;
747
- return threadMsgLine(token, parent, 40);
792
+ return threadMsgLine(token, parent, 40, cookie);
748
793
  }
749
794
 
750
795
  /** Normalize a message for duplicate comparison: lowercase, collapse whitespace,
@@ -800,6 +845,9 @@ interface EditArgs {
800
845
  code?: string;
801
846
  channelId?: string;
802
847
  mentions?: boolean;
848
+ // Session cookie (xoxd) for `token` — required for an xoxc- desktop session
849
+ // token to be accepted by the public Slack API at all.
850
+ cookie?: string;
803
851
  }
804
852
  async function cmdEdit(token: string, args: EditArgs): Promise<void> {
805
853
  const { ref, ts } = splitRefTs(args.target);
@@ -810,10 +858,10 @@ async function cmdEdit(token: string, args: EditArgs): Promise<void> {
810
858
 
811
859
  let channelId: string;
812
860
  if (args.channelId) channelId = args.channelId;
813
- else channelId = await resolveChannel(token, ref);
861
+ else channelId = await resolveChannel(token, ref, args.cookie);
814
862
 
815
863
  // Fetch the message to display the original text and compute the safety hash.
816
- const resp = (await replies(token, channelId, ts, 1)) as Record<string, Json>;
864
+ const resp = (await replies(token, channelId, ts, 1, args.cookie)) as Record<string, Json>;
817
865
  const msgs = asArray(resp.messages).map(asRecord);
818
866
  const original = msgs.find((m) => String(m.ts) === ts);
819
867
  if (!original) {
@@ -824,7 +872,7 @@ async function cmdEdit(token: string, args: EditArgs): Promise<void> {
824
872
 
825
873
  // Convert @handle → <@USERID> before hashing/editing (unresolved stay as text).
826
874
  const newText = args.mentions
827
- ? await encodeMentions(token, args.newText, channelId)
875
+ ? await encodeMentions(token, args.newText, channelId, args.cookie ? { cookie: args.cookie } : {})
828
876
  : args.newText;
829
877
 
830
878
  const code = safetyCode(originalText, newText);
@@ -838,7 +886,7 @@ async function cmdEdit(token: string, args: EditArgs): Promise<void> {
838
886
  ]);
839
887
  }
840
888
 
841
- const newTs = await editMessage(token, channelId, ts, newText);
889
+ const newTs = await editMessage(token, channelId, ts, newText, args.cookie);
842
890
  console.log(`✓ Edited (ts: ${newTs})`);
843
891
  }
844
892
 
@@ -847,6 +895,7 @@ interface DeleteArgs {
847
895
  target: string;
848
896
  code?: string;
849
897
  channelId?: string;
898
+ cookie?: string;
850
899
  }
851
900
  async function cmdDelete(token: string, args: DeleteArgs): Promise<void> {
852
901
  const { ref, ts } = splitRefTs(args.target);
@@ -857,10 +906,10 @@ async function cmdDelete(token: string, args: DeleteArgs): Promise<void> {
857
906
 
858
907
  let channelId: string;
859
908
  if (args.channelId) channelId = args.channelId;
860
- else channelId = await resolveChannel(token, ref);
909
+ else channelId = await resolveChannel(token, ref, args.cookie);
861
910
 
862
911
  // Fetch the message to display what is being deleted and compute the safety hash.
863
- const resp = (await replies(token, channelId, ts, 1)) as Record<string, Json>;
912
+ const resp = (await replies(token, channelId, ts, 1, args.cookie)) as Record<string, Json>;
864
913
  const msgs = asArray(resp.messages).map(asRecord);
865
914
  const original = msgs.find((m) => String(m.ts) === ts);
866
915
  if (!original) {
@@ -871,7 +920,7 @@ async function cmdDelete(token: string, args: DeleteArgs): Promise<void> {
871
920
 
872
921
  const code = safetyCode(channelId, ts, originalText);
873
922
  if (args.code !== code) {
874
- const dest = await destLabel(token, channelId, ref);
923
+ const dest = await destLabel(token, channelId, ref, args.cookie);
875
924
  requireCode(args.code, code, [
876
925
  `--- Deleting message -------------------------`,
877
926
  ` → ${dest} at ${slackTsToIso(ts)}`,
@@ -880,7 +929,7 @@ async function cmdDelete(token: string, args: DeleteArgs): Promise<void> {
880
929
  ]);
881
930
  }
882
931
 
883
- await deleteMessage(token, channelId, ts);
932
+ await deleteMessage(token, channelId, ts, args.cookie);
884
933
  console.log(`✓ Deleted (ts: ${ts})`);
885
934
  }
886
935
 
@@ -890,6 +939,7 @@ interface ReactArgs {
890
939
  emoji: string;
891
940
  remove?: boolean;
892
941
  channelId?: string;
942
+ cookie?: string;
893
943
  }
894
944
  // Add (or --remove) an emoji reaction. No confirm gate: a reaction is trivial
895
945
  // and fully reversible (`--remove`), and the whole point is a lightweight ack
@@ -905,13 +955,13 @@ async function cmdReact(token: string, args: ReactArgs): Promise<void> {
905
955
 
906
956
  let channelId: string;
907
957
  if (args.channelId) channelId = args.channelId;
908
- else channelId = await resolveChannel(token, ref);
958
+ else channelId = await resolveChannel(token, ref, args.cookie);
909
959
 
910
960
  if (args.remove) {
911
- await reactionRemove(token, channelId, ts, emoji);
961
+ await reactionRemove(token, channelId, ts, emoji, args.cookie);
912
962
  console.log(`✓ Removed :${emoji}: (ts: ${ts})`);
913
963
  } else {
914
- await reactionAdd(token, channelId, ts, emoji);
964
+ await reactionAdd(token, channelId, ts, emoji, args.cookie);
915
965
  console.log(`✓ Reacted :${emoji}: (ts: ${ts})`);
916
966
  }
917
967
  }
@@ -933,18 +983,22 @@ interface SendArgs {
933
983
  // Session cookie (xoxd) for the mention token — required for an xoxc- user
934
984
  // token to call users.list on the public API (it is rejected without it).
935
985
  mentionCookie?: string;
986
+ // Session cookie (xoxd) for `token` itself — required for an xoxc- desktop
987
+ // session token to be accepted by the public Slack API at all (chat.postMessage
988
+ // included). Not set when sending --as-bot (the bot token needs no cookie).
989
+ cookie?: string;
936
990
  }
937
991
 
938
992
  // Detect the silent-failure footgun: DMing yourself with your own user token.
939
993
  // Slack does not notify you about messages you sent to yourself, so an
940
994
  // escalation DM via `send @me` (or @your-own-handle) is delivered but never
941
995
  // surfaces. Returns true when ref names the token's own user.
942
- async function isSelfDm(token: string, ref: string): Promise<boolean> {
996
+ async function isSelfDm(token: string, ref: string, cookie?: string): Promise<boolean> {
943
997
  if (!ref.startsWith("@")) return false;
944
998
  const name = ref.slice(1).toLowerCase().replace(/[^a-z0-9]/g, "");
945
999
  if (name === "me" || name === "you") return true;
946
1000
  try {
947
- const self = await authTest(token);
1001
+ const self = await authTest(token, cookie);
948
1002
  const selfName = (self.user ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
949
1003
  return selfName !== "" && selfName === name;
950
1004
  } catch {
@@ -954,10 +1008,11 @@ async function isSelfDm(token: string, ref: string): Promise<boolean> {
954
1008
 
955
1009
  async function cmdSend(token: string, args: SendArgs): Promise<void> {
956
1010
  const { ref, threadTs } = parseTargetThread(args.target);
1011
+ const cookie = args.cookie;
957
1012
 
958
1013
  // Guard the self-DM footgun before doing anything else, unless sending as the
959
1014
  // bot (which delivers a notifiable DM from a different identity).
960
- if (!args.asBot && !args.channelId && !args.userId && await isSelfDm(token, ref)) {
1015
+ if (!args.asBot && !args.channelId && !args.userId && await isSelfDm(token, ref, cookie)) {
961
1016
  console.error(
962
1017
  `Warning: "${ref}" is a DM to yourself — Slack will NOT notify you of your own message.\n` +
963
1018
  ` To reach yourself with a notification, send as the bot: slack send '${ref}' '...' --as-bot`,
@@ -966,8 +1021,8 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
966
1021
 
967
1022
  let channelId: string;
968
1023
  if (args.channelId) channelId = args.channelId;
969
- else if (args.userId) channelId = await openDm(token, args.userId);
970
- else channelId = await resolveChannel(token, ref);
1024
+ else if (args.userId) channelId = await openDm(token, args.userId, cookie);
1025
+ else channelId = await resolveChannel(token, ref, cookie);
971
1026
 
972
1027
  // Convert @handle / @名前 tokens to <@USERID> before hashing/sending so the
973
1028
  // safety gate covers exactly what will be posted. Unresolved tokens stay as
@@ -1036,7 +1091,7 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
1036
1091
  // Fetch a generous window and preview its tail. conversations.replies is
1037
1092
  // oldest-first from the parent, so a very long thread's true tail may lie
1038
1093
  // beyond this window — 100 covers essentially all real threads.
1039
- const resp = (await replies(token, channelId, threadTs, 100)) as Record<string, Json>;
1094
+ const resp = (await replies(token, channelId, threadTs, 100, cookie)) as Record<string, Json>;
1040
1095
  threadMsgs = asArray(resp.messages).map(asRecord);
1041
1096
  const lastMsg = threadMsgs[threadMsgs.length - 1];
1042
1097
  // Bind the hash to the thread's most recent message: if someone replies
@@ -1050,7 +1105,7 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
1050
1105
  }
1051
1106
  } else {
1052
1107
  try {
1053
- const ctx = (await history(token, channelId, 1)) as Record<string, Json>;
1108
+ const ctx = (await history(token, channelId, 1, undefined, undefined, cookie)) as Record<string, Json>;
1054
1109
  // limit=1 already narrows this to exactly the channel's true last message
1055
1110
  // (subtype or not) — take it raw for the ownership check below. The
1056
1111
  // subtype filter is only for the human-readable preview text/author: it
@@ -1063,7 +1118,7 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
1063
1118
  // Resolve the author's user ID to a display name for the preview; fall back
1064
1119
  // to the bot username, then the raw ID. userName() is fail-soft.
1065
1120
  lastUser = typeof lastMsg?.user === "string"
1066
- ? await userName(token, lastMsg.user)
1121
+ ? await userName(token, lastMsg.user, cookie)
1067
1122
  : typeof lastMsg?.username === "string"
1068
1123
  ? lastMsg.username
1069
1124
  : "?";
@@ -1086,7 +1141,7 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
1086
1141
  // shape `send --as-bot` produces. Opt out with SLACK_UNREPLIED_WARN=0.
1087
1142
  if (process.env.SLACK_UNREPLIED_WARN !== "0" && lastMsgTs && (lastMsgUserId || lastMsgBotId)) {
1088
1143
  try {
1089
- const self = await authScopes(token);
1144
+ const self = await authScopes(token, cookie);
1090
1145
  const isSelfAuthor =
1091
1146
  (!!lastMsgUserId && !!self.userId && self.userId === lastMsgUserId) ||
1092
1147
  (!!lastMsgBotId && !!self.botId && self.botId === lastMsgBotId);
@@ -1100,7 +1155,7 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
1100
1155
  // embedded thread_ts colliding with the appended message ts.
1101
1156
  let editCmd = `slack edit "#${channelId}:${lastMsgTs}" "<新しい本文>" --channel-id ${channelId}`;
1102
1157
  try {
1103
- const permalink = await getPermalink(token, channelId, lastMsgTs);
1158
+ const permalink = await getPermalink(token, channelId, lastMsgTs, cookie);
1104
1159
  if (permalink) editCmd = `slack edit "${permalink}" "<新しい本文>"`;
1105
1160
  } catch {
1106
1161
  // fall back to the --channel-id form above
@@ -1128,7 +1183,7 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
1128
1183
  }
1129
1184
  if (best.msg && best.sim >= 0.82) {
1130
1185
  const pct = Math.round(best.sim * 100);
1131
- console.error(`⚠ possible duplicate: ${pct}% similar to an existing thread message — ${await threadMsgLine(token, best.msg)}`);
1186
+ console.error(`⚠ possible duplicate: ${pct}% similar to an existing thread message — ${await threadMsgLine(token, best.msg, 60, cookie)}`);
1132
1187
  }
1133
1188
  }
1134
1189
 
@@ -1137,14 +1192,14 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
1137
1192
  const code = safetyCode(channelId, threadTs ?? "", lastText, message);
1138
1193
 
1139
1194
  if (args.code !== code) {
1140
- const dest = await destLabel(token, channelId, ref);
1195
+ const dest = await destLabel(token, channelId, ref, cookie);
1141
1196
  if (threadTs) {
1142
- const parentLine = await threadParentLine(token, threadTs, threadMsgs);
1197
+ const parentLine = await threadParentLine(token, threadTs, threadMsgs, cookie);
1143
1198
  // Preview the tail of the thread (up to 3 most recent messages) so the
1144
1199
  // sender can see what's already been said and avoid repeating it.
1145
1200
  const recent = threadMsgs.slice(-3);
1146
1201
  const recentLines = recent.length
1147
- ? await Promise.all(recent.map(async (m) => ` ${await threadMsgLine(token, m)}`))
1202
+ ? await Promise.all(recent.map(async (m) => ` ${await threadMsgLine(token, m, 60, cookie)}`))
1148
1203
  : [" (thread context unavailable)"];
1149
1204
  requireCode(args.code, code, [
1150
1205
  `--- Recent messages in thread ----------------`,
@@ -1167,10 +1222,10 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
1167
1222
  ]);
1168
1223
  }
1169
1224
  }
1170
- const ts = await slackSend(token, channelId, message, threadTs, args.broadcast);
1225
+ const ts = await slackSend(token, channelId, message, threadTs, args.broadcast, cookie);
1171
1226
  let permalink = "";
1172
1227
  try {
1173
- permalink = await getPermalink(token, channelId, ts);
1228
+ permalink = await getPermalink(token, channelId, ts, cookie);
1174
1229
  } catch {
1175
1230
  // fail-soft: getPermalink failure (rate limit, network, etc.) should not
1176
1231
  // mask send success — fall back to ts-only output below.
@@ -1224,14 +1279,15 @@ interface ScheduleSendArgs {
1224
1279
  code?: string;
1225
1280
  channelId?: string;
1226
1281
  userId?: string;
1282
+ cookie?: string;
1227
1283
  }
1228
1284
  async function cmdScheduleSend(token: string, args: ScheduleSendArgs): Promise<void> {
1229
1285
  const { ref, threadTs } = parseTargetThread(args.target);
1230
1286
 
1231
1287
  let channelId: string;
1232
1288
  if (args.channelId) channelId = args.channelId;
1233
- else if (args.userId) channelId = await openDm(token, args.userId);
1234
- else channelId = await resolveChannel(token, ref);
1289
+ else if (args.userId) channelId = await openDm(token, args.userId, args.cookie);
1290
+ else channelId = await resolveChannel(token, ref, args.cookie);
1235
1291
 
1236
1292
  const postAt = parsePostAt(args.at);
1237
1293
  const postAtDate = new Date(postAt * 1000).toISOString();
@@ -1246,18 +1302,18 @@ async function cmdScheduleSend(token: string, args: ScheduleSendArgs): Promise<v
1246
1302
  `---------------------------------------------`,
1247
1303
  ]);
1248
1304
  }
1249
- const id = await scheduleMessage(token, channelId, args.message, postAt, threadTs);
1305
+ const id = await scheduleMessage(token, channelId, args.message, postAt, threadTs, args.cookie);
1250
1306
  console.log(`✓ Scheduled (id: ${id}, at: ${postAtDate})`);
1251
1307
  }
1252
1308
 
1253
- async function cmdScheduleList(token: string, target?: string, channelId?: string): Promise<void> {
1309
+ async function cmdScheduleList(token: string, target?: string, channelId?: string, cookie?: string): Promise<void> {
1254
1310
  let channel: string | undefined;
1255
1311
  if (channelId) {
1256
1312
  channel = channelId;
1257
1313
  } else if (target) {
1258
- channel = await resolveChannel(token, target);
1314
+ channel = await resolveChannel(token, target, cookie);
1259
1315
  }
1260
- const resp = (await listScheduledMessages(token, channel)) as {
1316
+ const resp = (await listScheduledMessages(token, channel, cookie)) as {
1261
1317
  scheduled_messages?: { id: string; channel_id: string; post_at: number; text: string }[];
1262
1318
  };
1263
1319
  const msgs = resp.scheduled_messages ?? [];
@@ -1273,11 +1329,12 @@ interface ScheduleRmArgs {
1273
1329
  id: string;
1274
1330
  code?: string;
1275
1331
  channelId?: string;
1332
+ cookie?: string;
1276
1333
  }
1277
1334
  async function cmdScheduleRm(token: string, args: ScheduleRmArgs): Promise<void> {
1278
1335
  let channelId: string;
1279
1336
  if (args.channelId) channelId = args.channelId;
1280
- else channelId = await resolveChannel(token, args.target);
1337
+ else channelId = await resolveChannel(token, args.target, args.cookie);
1281
1338
 
1282
1339
  const code = safetyCode(channelId, args.id);
1283
1340
  if (args.code !== code) {
@@ -1288,7 +1345,7 @@ async function cmdScheduleRm(token: string, args: ScheduleRmArgs): Promise<void>
1288
1345
  `---------------------------------------------`,
1289
1346
  ]);
1290
1347
  }
1291
- await deleteScheduledMessage(token, channelId, args.id);
1348
+ await deleteScheduledMessage(token, channelId, args.id, args.cookie);
1292
1349
  console.log(`✓ Deleted scheduled message ${args.id}`);
1293
1350
  }
1294
1351
 
@@ -1301,6 +1358,7 @@ interface UploadArgs {
1301
1358
  code?: string;
1302
1359
  channelId?: string;
1303
1360
  userId?: string;
1361
+ cookie?: string;
1304
1362
  }
1305
1363
  async function cmdUpload(token: string, args: UploadArgs): Promise<void> {
1306
1364
  const { statSync, existsSync } = await import("node:fs");
@@ -1317,8 +1375,8 @@ async function cmdUpload(token: string, args: UploadArgs): Promise<void> {
1317
1375
 
1318
1376
  let channelId: string;
1319
1377
  if (args.channelId) channelId = args.channelId;
1320
- else if (args.userId) channelId = await openDm(token, args.userId);
1321
- else channelId = await resolveChannel(token, ref);
1378
+ else if (args.userId) channelId = await openDm(token, args.userId, args.cookie);
1379
+ else channelId = await resolveChannel(token, ref, args.cookie);
1322
1380
 
1323
1381
  const isBatch = args.filePaths.length > 1;
1324
1382
  const files = args.filePaths.map((fp) => {
@@ -1355,12 +1413,60 @@ async function cmdUpload(token: string, args: UploadArgs): Promise<void> {
1355
1413
  const uploadOpts: { title?: string; threadTs?: string; initialComment?: string } = { title: f.title };
1356
1414
  if (threadTs !== undefined) uploadOpts.threadTs = threadTs;
1357
1415
  if (args.comment !== undefined && i === 0) uploadOpts.initialComment = args.comment;
1358
- const { fileId, permalink } = await uploadFile(token, channelId, f.fp, uploadOpts);
1416
+ const { fileId, permalink } = await uploadFile(token, channelId, f.fp, uploadOpts, args.cookie);
1359
1417
  const prefix = total > 1 ? `[${i + 1}/${total}] ` : "";
1360
1418
  console.log(`${prefix}✓ Uploaded (file_id: ${fileId}${permalink ? `, url: ${permalink}` : ""})`);
1361
1419
  }
1362
1420
  }
1363
1421
 
1422
+ // --- files ls — list files visible to the token (files.list, read-only) ---
1423
+ // Walks files.list pages until it has `limit` rows or runs out. Canvases and
1424
+ // Slack Lists from the web "unified files" view mostly don't appear here (the
1425
+ // token can't reach those internal APIs) — this lists real file uploads.
1426
+ async function cmdFilesList(
1427
+ token: string,
1428
+ cookie: string | undefined,
1429
+ opts: { limit: number; types?: string; channel?: string; user?: string; format: string },
1430
+ ): Promise<void> {
1431
+ const channelId = opts.channel ? await resolveChannel(token, opts.channel, cookie) : undefined;
1432
+ const files: Record<string, Json>[] = [];
1433
+ let page = 1;
1434
+ let pages = 1;
1435
+ do {
1436
+ const listOpts: { page: number; count: number; types?: string; channel?: string; user?: string } = { page, count: 200 };
1437
+ if (opts.types) listOpts.types = opts.types;
1438
+ if (channelId) listOpts.channel = channelId;
1439
+ if (opts.user) listOpts.user = opts.user;
1440
+ const resp = asRecord((await filesList(token, listOpts, cookie)) as Json);
1441
+ for (const f of asArray(resp.files)) files.push(asRecord(f));
1442
+ pages = Number(asRecord(resp.paging).pages ?? 1);
1443
+ page++;
1444
+ } while (files.length < opts.limit && page <= pages);
1445
+ const shown = files.slice(0, opts.limit);
1446
+
1447
+ if (opts.format === "jsonl") {
1448
+ for (const f of shown) {
1449
+ console.log(JSON.stringify({
1450
+ id: f.id ?? null,
1451
+ name: f.name ?? f.title ?? null,
1452
+ filetype: f.filetype ?? null,
1453
+ size: f.size ?? null,
1454
+ user: f.user ?? null,
1455
+ created: f.created ?? null,
1456
+ permalink: f.permalink ?? null,
1457
+ }));
1458
+ }
1459
+ return;
1460
+ }
1461
+ for (const f of shown) {
1462
+ const id = String(f.id ?? "");
1463
+ const name = typeof f.name === "string" ? f.name : typeof f.title === "string" ? f.title : id;
1464
+ const ft = typeof f.filetype === "string" ? f.filetype : "?";
1465
+ const size = typeof f.size === "number" ? fmtSize(f.size) : "";
1466
+ console.log(`${id} ${ft.padEnd(7)} ${size.padStart(9)} ${name}`);
1467
+ }
1468
+ }
1469
+
1364
1470
  // --- download <ref> [dest] — fetch an attachment's bytes to disk (read-only) ---
1365
1471
  // ref: a file ID (F…) or a Slack file permalink (…/files/<UID>/<FID>/<name>).
1366
1472
  function parseFileId(ref: string): string | undefined {
@@ -1491,10 +1597,11 @@ async function main(): Promise<void> {
1491
1597
  (y2) => y2.positional("channel", { type: "string", demandOption: true, describe: "#name or channel ID" }),
1492
1598
  async (argv) => {
1493
1599
  const token = tok(argv as W);
1600
+ const cookie = ck(argv as W);
1494
1601
  const ref = argv.channel!;
1495
1602
  const channelRef = ref.startsWith("#") || ref.startsWith("@") || ref.startsWith("C") || ref.startsWith("G") || ref.startsWith("D") ? ref : `#${ref}`;
1496
- const channelId = await resolveChannel(token, channelRef);
1497
- const resp = asRecord((await conversationInfo(token, channelId)) as Json);
1603
+ const channelId = await resolveChannel(token, channelRef, cookie);
1604
+ const resp = asRecord((await conversationInfo(token, channelId, cookie)) as Json);
1498
1605
  const ch = asRecord(resp.channel);
1499
1606
  const name = typeof ch.name === "string" ? ch.name : channelId;
1500
1607
  const isIm = ch.is_im === true;
@@ -1519,6 +1626,7 @@ async function main(): Promise<void> {
1519
1626
  .option("code", { type: "string", describe: "Safety hash to confirm create" }),
1520
1627
  async (argv) => {
1521
1628
  const token = tok(argv as W);
1629
+ const cookie = ck(argv as W);
1522
1630
  // Slack lowercases and strips the leading #; normalize for preview + code.
1523
1631
  const name = argv.name!.replace(/^#/, "").toLowerCase();
1524
1632
  const isPrivate = argv.private === true;
@@ -1533,7 +1641,7 @@ async function main(): Promise<void> {
1533
1641
  ]);
1534
1642
  let created: { id: string; name: string };
1535
1643
  try {
1536
- created = await createChannel(token, name, isPrivate);
1644
+ created = await createChannel(token, name, isPrivate, cookie);
1537
1645
  } catch (e: unknown) {
1538
1646
  console.error(e instanceof Error ? e.message : String(e));
1539
1647
  process.exit(1);
@@ -1543,14 +1651,32 @@ async function main(): Promise<void> {
1543
1651
  const ids: string[] = [];
1544
1652
  for (const ref of invites) {
1545
1653
  try {
1546
- ids.push(ref.startsWith("U") || ref.startsWith("W") ? ref : await resolveUserId(token, ref));
1654
+ ids.push(ref.startsWith("U") || ref.startsWith("W") ? ref : await resolveUserId(token, ref, cookie));
1547
1655
  } catch (e: unknown) {
1548
1656
  console.error(` ! could not resolve ${ref}: ${e instanceof Error ? e.message : String(e)}`);
1549
1657
  }
1550
1658
  }
1659
+ // conversations.invite returns ok:true but silently no-ops for a
1660
+ // single-channel guest (is_ultra_restricted) — they can only ever
1661
+ // belong to the one channel they were created in. Warn up front
1662
+ // (fail-soft, never blocks) so a no-op invite isn't mistaken for
1663
+ // success.
1664
+ for (const uid of ids) {
1665
+ try {
1666
+ const info = asRecord((await userInfo(token, uid, cookie)) as Json);
1667
+ const u = asRecord(info.user);
1668
+ if (u.is_ultra_restricted === true) {
1669
+ console.error(`⚠ ${uid} is a single-channel guest — Slack restricts them to their one existing channel; this invite will likely report success but not actually add them.`);
1670
+ } else if (u.is_restricted === true) {
1671
+ console.error(`⚠ ${uid} is a multi-channel guest — double-check they were actually added (guest invites can silently no-op depending on workspace restrictions).`);
1672
+ }
1673
+ } catch {
1674
+ // best-effort; a lookup failure must not block the invite
1675
+ }
1676
+ }
1551
1677
  if (ids.length) {
1552
1678
  try {
1553
- await inviteToChannel(token, created.id, ids);
1679
+ await inviteToChannel(token, created.id, ids, cookie);
1554
1680
  console.log(`✓ Invited ${ids.length} member${ids.length === 1 ? "" : "s"}`);
1555
1681
  } catch (e: unknown) {
1556
1682
  console.error(` ! invite failed: ${e instanceof Error ? e.message : String(e)}`);
@@ -1681,7 +1807,7 @@ async function main(): Promise<void> {
1681
1807
  .option("count", { alias: "n", type: "number", default: 100 })
1682
1808
  .option("json", { type: "boolean", default: false, describe: "Output raw JSON" }),
1683
1809
  async (argv) => {
1684
- await cmdSearch(tok(argv as W), argv.query!, argv.count, argv.json);
1810
+ await cmdSearch(tok(argv as W), argv.query!, argv.count, argv.json, ck(argv as W));
1685
1811
  },
1686
1812
  )
1687
1813
  .command(
@@ -1730,7 +1856,7 @@ async function main(): Promise<void> {
1730
1856
  // (not the user's own self-DM).
1731
1857
  if (!args.channelId && !args.userId && args.target.startsWith("@")) {
1732
1858
  try {
1733
- args.userId = await resolveUserId(tok(argv as W), args.target);
1859
+ args.userId = await resolveUserId(tok(argv as W), args.target, ck(argv as W));
1734
1860
  } catch (e: unknown) {
1735
1861
  console.error(
1736
1862
  `Error: could not resolve ${args.target} to a user for the bot DM.\n` +
@@ -1744,6 +1870,11 @@ async function main(): Promise<void> {
1744
1870
  args.asBot = true;
1745
1871
  } else {
1746
1872
  sendToken = tok(argv as W);
1873
+ // Session cookie for `sendToken` itself — needed for an xoxc- desktop
1874
+ // token to be accepted by the public API at all. Not applicable to the
1875
+ // bot token above (bot tokens need no cookie).
1876
+ const sc = ck(argv as W);
1877
+ if (sc) args.cookie = sc;
1747
1878
  }
1748
1879
  // Print a clean error instead of yargs' usage dump when a send fails at
1749
1880
  // runtime (e.g. missing scope, channel not found). The confirm gate
@@ -1791,6 +1922,8 @@ async function main(): Promise<void> {
1791
1922
  if (argv.code) args.code = argv.code;
1792
1923
  if (argv["channel-id"]) args.channelId = argv["channel-id"];
1793
1924
  if (argv["user-id"]) args.userId = argv["user-id"];
1925
+ const cookie = ck(argv as W);
1926
+ if (cookie) args.cookie = cookie;
1794
1927
  await cmdScheduleSend(tok(argv as W), args);
1795
1928
  },
1796
1929
  )
@@ -1801,7 +1934,7 @@ async function main(): Promise<void> {
1801
1934
  .positional("target", { type: "string", describe: "#channel to filter by" })
1802
1935
  .option("channel-id", { type: "string", describe: "Raw channel ID" }),
1803
1936
  async (argv) => {
1804
- await cmdScheduleList(tok(argv as W), argv.target as string | undefined, argv["channel-id"]);
1937
+ await cmdScheduleList(tok(argv as W), argv.target as string | undefined, argv["channel-id"], ck(argv as W));
1805
1938
  },
1806
1939
  )
1807
1940
  .command(
@@ -1815,6 +1948,8 @@ async function main(): Promise<void> {
1815
1948
  async (argv) => {
1816
1949
  const args: ScheduleRmArgs = { target: argv.target!, id: argv.id! };
1817
1950
  if (argv.code) args.code = argv.code;
1951
+ const cookie = ck(argv as W);
1952
+ if (cookie) args.cookie = cookie;
1818
1953
  if (argv["channel-id"]) args.channelId = argv["channel-id"];
1819
1954
  await cmdScheduleRm(tok(argv as W), args);
1820
1955
  },
@@ -1837,6 +1972,8 @@ async function main(): Promise<void> {
1837
1972
  if (argv.code) args.code = argv.code;
1838
1973
  if (argv["channel-id"]) args.channelId = argv["channel-id"];
1839
1974
  if (argv.mentions !== false) args.mentions = true;
1975
+ const cookie = ck(argv as W);
1976
+ if (cookie) args.cookie = cookie;
1840
1977
  await cmdEdit(tok(argv as W), args);
1841
1978
  },
1842
1979
  )
@@ -1851,6 +1988,8 @@ async function main(): Promise<void> {
1851
1988
  const args: DeleteArgs = { target: argv.target! };
1852
1989
  if (argv.code) args.code = argv.code;
1853
1990
  if (argv["channel-id"]) args.channelId = argv["channel-id"];
1991
+ const cookie = ck(argv as W);
1992
+ if (cookie) args.cookie = cookie;
1854
1993
  await cmdDelete(tok(argv as W), args);
1855
1994
  },
1856
1995
  )
@@ -1866,6 +2005,8 @@ async function main(): Promise<void> {
1866
2005
  const args: ReactArgs = { target: argv.target!, emoji: argv.emoji! };
1867
2006
  if (argv.remove) args.remove = true;
1868
2007
  if (argv["channel-id"]) args.channelId = argv["channel-id"];
2008
+ const cookie = ck(argv as W);
2009
+ if (cookie) args.cookie = cookie;
1869
2010
  // Print a clean one-line error instead of yargs' usage dump + stack when
1870
2011
  // the reaction fails at runtime (missing_scope, invalid_name, etc.).
1871
2012
  try {
@@ -1894,9 +2035,40 @@ async function main(): Promise<void> {
1894
2035
  if (argv.code) args.code = argv.code;
1895
2036
  if (argv["channel-id"]) args.channelId = argv["channel-id"];
1896
2037
  if (argv["user-id"]) args.userId = argv["user-id"];
2038
+ const cookie = ck(argv as W);
2039
+ if (cookie) args.cookie = cookie;
1897
2040
  await cmdUpload(tok(argv as W), args);
1898
2041
  },
1899
2042
  )
2043
+ .command(
2044
+ "files",
2045
+ "File commands",
2046
+ (y) => y
2047
+ .command(
2048
+ ["ls", "list"],
2049
+ "List files visible to you (files.list)",
2050
+ (y2) => y2
2051
+ .option("limit", { alias: "l", type: "number", default: 100, describe: "Max files to list" })
2052
+ .option("type", { alias: "t", type: "string", describe: "Slack type filter: images, pdfs, gdocs, snippets, zips, spaces, all" })
2053
+ .option("channel", { alias: "c", type: "string", describe: "Only files in this channel (#name, @user, ID, or permalink)" })
2054
+ .option("user", { alias: "u", type: "string", describe: "Only files from this raw user ID" })
2055
+ .option("format", { type: "string", choices: ["text", "jsonl"], default: "text" })
2056
+ .option("json", { type: "boolean", default: false, describe: "Alias for --format=jsonl" }),
2057
+ async (argv) => {
2058
+ const o: { limit: number; types?: string; channel?: string; user?: string; format: string } = {
2059
+ limit: argv.limit as number,
2060
+ format: argv.json ? "jsonl" : (argv.format as string),
2061
+ };
2062
+ if (argv.type) o.types = argv.type as string;
2063
+ if (argv.channel) o.channel = argv.channel as string;
2064
+ if (argv.user) o.user = argv.user as string;
2065
+ await cmdFilesList(tok(argv as W), ck(argv as W), o);
2066
+ },
2067
+ )
2068
+ .demandCommand(1, "")
2069
+ .showHelpOnFail(true),
2070
+ () => {},
2071
+ )
1900
2072
  .command(
1901
2073
  "download <ref> [dest]",
1902
2074
  "Download a file attachment (by file ID or file permalink) to disk",